feat(media): manage client and project thumbnails

This commit is contained in:
2026-05-26 12:16:06 +03:30
parent c895b8f44d
commit f30ea5d395
9 changed files with 344 additions and 65 deletions

View File

@@ -5,6 +5,7 @@ export interface Client {
id: string
name: string
notes?: string
thumbnail?: string | null
}
interface PaginatedResponse<T> {
@@ -36,14 +37,36 @@ export const getClients = async (
});
};
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => {
const response = await authFetch("/api/clients/", {
method: "POST",
body: JSON.stringify({
workspace_id: workspaceId,
...data,
}),
});
const buildClientBody = (
workspaceId: string | null,
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
) => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
if (!hasFile && !shouldClear) {
return {
body: JSON.stringify({
...(workspaceId ? { workspace_id: workspaceId } : {}),
...data,
}),
};
}
const formData = new FormData();
if (workspaceId) formData.append("workspace_id", workspaceId);
if (data.name !== undefined) formData.append("name", data.name);
if (data.notes !== undefined) formData.append("notes", data.notes);
if (data.thumbnail) formData.append("thumbnail", data.thumbnail);
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const createClient = async (workspaceId: string, data: { name: string; notes: string; thumbnail?: File | null }) => {
const requestBody = buildClientBody(workspaceId, data);
const response = await authFetch("/api/clients/", {
method: "POST",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
@@ -54,11 +77,15 @@ export const createClient = async (workspaceId: string, data: { name: string; no
return payload;
};
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => {
const response = await authFetch(`/api/clients/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
export const updateClient = async (
id: string,
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
) => {
const requestBody = buildClientBody(null, data);
const response = await authFetch(`/api/clients/${id}/`, {
method: "PATCH",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);

View File

@@ -11,6 +11,7 @@ interface AuditUser {
export interface ProjectClient {
id: string;
name: string;
thumbnail?: string | null;
}
export interface ProjectAccessRateValue {
@@ -25,6 +26,7 @@ export interface Project {
name: string;
description: string;
color: string;
thumbnail?: string | null;
created_at?: string;
is_archived: boolean;
is_deleted?: boolean;
@@ -60,10 +62,32 @@ export interface ProjectPayload {
name: string;
description: string;
color: string;
is_archived: boolean;
workspace: string;
client: string | null;
}
is_archived: boolean;
workspace: string;
client: string | null;
thumbnail?: File | null;
clear_thumbnail?: boolean;
}
const buildProjectBody = (data: Partial<ProjectPayload> & { workspace?: string; name?: string }) => {
const hasFile = data.thumbnail instanceof File;
const shouldClear = Boolean(data.clear_thumbnail);
if (!hasFile && !shouldClear) {
return { body: JSON.stringify(data) };
}
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value === undefined || value === null || key === "clear_thumbnail") return;
if (key === "thumbnail" && value instanceof File) {
formData.append(key, value);
return;
}
formData.append(key, String(value));
});
if (shouldClear) formData.append("clear_thumbnail", "true");
return { body: formData };
};
export const getProjects = async (
workspaceId: string,
@@ -115,10 +139,11 @@ export const getProject = async (id: string) => {
export const createProject = async (
data: Partial<ProjectPayload> & { workspace: string; name: string }
) => {
const response = await authFetch("/api/projects/", {
method: "POST",
body: JSON.stringify(data),
});
const requestBody = buildProjectBody(data);
const response = await authFetch("/api/projects/", {
method: "POST",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
@@ -133,10 +158,11 @@ export const updateProject = async (
id: string,
data: Partial<ProjectPayload>
) => {
const response = await authFetch(`/api/projects/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
const requestBody = buildProjectBody(data);
const response = await authFetch(`/api/projects/${id}/`, {
method: "PATCH",
body: requestBody.body,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { createClient } from "../api/clients";
import { useTranslation } from "../hooks/useTranslation";
@@ -17,18 +17,47 @@ interface CreateClientModalProps {
export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [notes, setNotes] = useState("");
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
const handleSubmit = async () => {
if (!name.trim()) return;
setIsLoading(true);
try {
await createClient(workspaceId, { name, notes });
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
toast.success(t.clients.createSuccess);
onSuccess();
setName("");
setNotes("");
setThumbnailFile(null);
onClose();
} catch (error) {
console.error(t.clients.errors.createFailed, error);
@@ -52,7 +81,23 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
<div className="space-y-4">
<div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : name.trim().charAt(0).toUpperCase() || "C"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<Button type="button" variant="outline" onClick={() => setThumbnailFile(null)}>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName}
</label>

View File

@@ -17,22 +17,53 @@ interface EditClientModalProps {
export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client) {
setName(client.name);
setNotes(client.notes || "");
}
}, [client]);
setName(client.name);
setNotes(client.notes || "");
setThumbnailUrl(client.thumbnail || null);
setThumbnailFile(null);
setClearThumbnail(false);
}
}, [client]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async () => {
if (!client || !name.trim()) return;
setIsLoading(true);
try {
await updateClient(client.id, { name, notes });
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
toast.success(t.clients.updateSuccess);
onSuccess();
onClose();
@@ -58,7 +89,36 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
return (
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
<div className="space-y-4">
<div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<Button
type="button"
variant="outline"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
>
{t.remove || "Remove"}
</Button>
) : null}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
{t.clients.clientName}
</label>

View File

@@ -21,13 +21,41 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [loadingClients, setLoadingClients] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) {
setThumbnailFile(null);
return;
}
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
};
useEffect(() => {
if (isOpen && activeWorkspace) {
@@ -49,14 +77,16 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
workspace: activeWorkspace.id,
name: formData.name,
description: formData.description,
color: formData.color,
client: formData.client || null,
color: formData.color,
client: formData.client || null,
thumbnail: thumbnailFile,
});
toast.success(t.projects?.createSuccess || "Project created successfully.");
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
onClose();
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
setThumbnailFile(null);
} catch (error) {
console.error(error);
toast.error(t.projects?.createError || "Failed to create project.");
@@ -114,7 +144,24 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
</div>
</div>
<div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? <img src={thumbnailPreview} alt="" className="h-full w-full object-cover" /> : formData.name.trim().charAt(0).toUpperCase() || "P"}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{thumbnailFile ? (
<button type="button" onClick={() => setThumbnailFile(null)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600">
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'}
</label>

View File

@@ -24,13 +24,17 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [loadingClients, setLoadingClients] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
client: "",
});
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [clearThumbnail, setClearThumbnail] = useState(false);
const [loadingClients, setLoadingClients] = useState(false);
useEffect(() => {
if (isOpen && activeWorkspace) {
@@ -47,11 +51,38 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
setFormData({
name: project.name || "",
description: project.description || "",
color: project.color || "#3B82F6",
client: project.client ? project.client.id : "",
});
}
}, [project]);
color: project.color || "#3B82F6",
client: project.client ? project.client.id : "",
});
setThumbnailUrl(project.thumbnail || null);
setThumbnailFile(null);
setClearThumbnail(false);
}
}, [project]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailPreview(null);
return;
}
const objectUrl = URL.createObjectURL(thumbnailFile);
setThumbnailPreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [thumbnailFile]);
const handleThumbnailChange = (file: File | null) => {
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error(t.workspace?.thumbnailInvalidType || "Unsupported image type. Use JPG, PNG, or WebP.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t.workspace?.thumbnailMaxSizeError || "Image size must be 2MB or less.");
return;
}
setThumbnailFile(file);
setClearThumbnail(false);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -64,6 +95,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
description: formData.description,
color: formData.color,
client: formData.client || null,
thumbnail: thumbnailFile,
clear_thumbnail: clearThumbnail,
});
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
@@ -164,7 +197,37 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
</div>
</div>
<div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.workspace?.thumbnailLabel || "Thumbnail"}
</label>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl text-sm font-semibold text-white" style={{ backgroundColor: formData.color || "#3B82F6" }}>
{thumbnailPreview ? (
<img src={thumbnailPreview} alt="" className="h-full w-full object-cover" />
) : !clearThumbnail && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
) : (
formData.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<Input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => handleThumbnailChange(event.target.files?.[0] || null)} />
{(thumbnailFile || (!clearThumbnail && thumbnailUrl)) ? (
<button
type="button"
onClick={() => {
setThumbnailFile(null);
setClearThumbnail(true);
}}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600"
>
{t.remove || "Remove"}
</button>
) : null}
</div>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
{t.projects?.descriptionLabel || 'Description'}
</label>

View File

@@ -190,7 +190,11 @@ export default function Clients() {
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{client.name.trim().charAt(0).toUpperCase() || "C"}
{client.thumbnail ? (
<img src={client.thumbnail} alt={client.name} className="h-full w-full rounded-xl object-cover" />
) : (
client.name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>

View File

@@ -374,9 +374,15 @@ export const Projects: React.FC = () => {
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-slate-200 text-sm font-semibold text-white dark:border-slate-700"
style={{ backgroundColor: project.color || "#3B82F6" }}
/>
>
{project.thumbnail ? (
<img src={project.thumbnail} alt={project.name} className="h-full w-full object-cover" />
) : (
project.name.trim().charAt(0).toUpperCase() || "P"
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">

View File

@@ -9,6 +9,7 @@ export interface Client {
id: string;
name: string;
notes: string | null;
thumbnail?: string | null;
workspace: string;
created_by?: AuditUser | null;
can_delete: boolean;