feat(media): manage client and project thumbnails
This commit is contained in:
@@ -5,6 +5,7 @@ export interface Client {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
thumbnail?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
interface PaginatedResponse<T> {
|
||||||
@@ -36,14 +37,36 @@ export const getClients = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => {
|
const buildClientBody = (
|
||||||
const response = await authFetch("/api/clients/", {
|
workspaceId: string | null,
|
||||||
method: "POST",
|
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
|
||||||
body: JSON.stringify({
|
) => {
|
||||||
workspace_id: workspaceId,
|
const hasFile = data.thumbnail instanceof File;
|
||||||
...data,
|
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) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
@@ -54,11 +77,15 @@ export const createClient = async (workspaceId: string, data: { name: string; no
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateClient = async (id: string, data: { name?: string; notes?: string }) => {
|
export const updateClient = async (
|
||||||
const response = await authFetch(`/api/clients/${id}/`, {
|
id: string,
|
||||||
method: "PATCH",
|
data: { name?: string; notes?: string; thumbnail?: File | null; clear_thumbnail?: boolean },
|
||||||
body: JSON.stringify(data),
|
) => {
|
||||||
});
|
const requestBody = buildClientBody(null, data);
|
||||||
|
const response = await authFetch(`/api/clients/${id}/`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: requestBody.body,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface AuditUser {
|
|||||||
export interface ProjectClient {
|
export interface ProjectClient {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectAccessRateValue {
|
export interface ProjectAccessRateValue {
|
||||||
@@ -25,6 +26,7 @@ export interface Project {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
@@ -60,10 +62,32 @@ export interface ProjectPayload {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
client: string | null;
|
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 (
|
export const getProjects = async (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -115,10 +139,11 @@ export const getProject = async (id: string) => {
|
|||||||
export const createProject = async (
|
export const createProject = async (
|
||||||
data: Partial<ProjectPayload> & { workspace: string; name: string }
|
data: Partial<ProjectPayload> & { workspace: string; name: string }
|
||||||
) => {
|
) => {
|
||||||
const response = await authFetch("/api/projects/", {
|
const requestBody = buildProjectBody(data);
|
||||||
method: "POST",
|
const response = await authFetch("/api/projects/", {
|
||||||
body: JSON.stringify(data),
|
method: "POST",
|
||||||
});
|
body: requestBody.body,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
@@ -133,10 +158,11 @@ export const updateProject = async (
|
|||||||
id: string,
|
id: string,
|
||||||
data: Partial<ProjectPayload>
|
data: Partial<ProjectPayload>
|
||||||
) => {
|
) => {
|
||||||
const response = await authFetch(`/api/projects/${id}/`, {
|
const requestBody = buildProjectBody(data);
|
||||||
method: "PATCH",
|
const response = await authFetch(`/api/projects/${id}/`, {
|
||||||
body: JSON.stringify(data),
|
method: "PATCH",
|
||||||
});
|
body: requestBody.body,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createClient } from "../api/clients";
|
import { createClient } from "../api/clients";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
@@ -17,18 +17,47 @@ interface CreateClientModalProps {
|
|||||||
export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) {
|
export default function CreateClientModal({ isOpen, onClose, onSuccess, workspaceId }: CreateClientModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await createClient(workspaceId, { name, notes });
|
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
|
||||||
toast.success(t.clients.createSuccess);
|
toast.success(t.clients.createSuccess);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
setName("");
|
setName("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setThumbnailFile(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t.clients.errors.createFailed, error);
|
console.error(t.clients.errors.createFailed, error);
|
||||||
@@ -52,7 +81,23 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
||||||
<div className="space-y-4">
|
<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">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.clients.clientName}
|
{t.clients.clientName}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -17,22 +17,53 @@ interface EditClientModalProps {
|
|||||||
|
|
||||||
export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) {
|
export default function EditClientModal({ isOpen, onClose, onSuccess, client }: EditClientModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (client) {
|
if (client) {
|
||||||
setName(client.name);
|
setName(client.name);
|
||||||
setNotes(client.notes || "");
|
setNotes(client.notes || "");
|
||||||
}
|
setThumbnailUrl(client.thumbnail || null);
|
||||||
}, [client]);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!client || !name.trim()) return;
|
if (!client || !name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateClient(client.id, { name, notes });
|
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
|
||||||
toast.success(t.clients.updateSuccess);
|
toast.success(t.clients.updateSuccess);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -58,7 +89,36 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
||||||
<div className="space-y-4">
|
<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">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.clients.clientName}
|
{t.clients.clientName}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -21,13 +21,41 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
const canCreateProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_CREATE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
color: "#3B82F6",
|
color: "#3B82F6",
|
||||||
client: "",
|
client: "",
|
||||||
});
|
});
|
||||||
const [loadingClients, setLoadingClients] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && activeWorkspace) {
|
if (isOpen && activeWorkspace) {
|
||||||
@@ -49,14 +77,16 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
workspace: activeWorkspace.id,
|
workspace: activeWorkspace.id,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
client: formData.client || null,
|
client: formData.client || null,
|
||||||
|
thumbnail: thumbnailFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
toast.success(t.projects?.createSuccess || "Project created successfully.");
|
||||||
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
|
||||||
onClose();
|
onClose();
|
||||||
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
|
||||||
|
setThumbnailFile(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(t.projects?.createError || "Failed to create project.");
|
toast.error(t.projects?.createError || "Failed to create project.");
|
||||||
@@ -114,7 +144,24 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{t.projects?.descriptionLabel || 'Description'}
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -24,13 +24,17 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
|
const canArchiveProject = canWorkspace(activeWorkspace?.my_role, PROJECTS_ARCHIVE);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
color: "#3B82F6",
|
color: "#3B82F6",
|
||||||
client: "",
|
client: "",
|
||||||
});
|
});
|
||||||
const [loadingClients, setLoadingClients] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && activeWorkspace) {
|
if (isOpen && activeWorkspace) {
|
||||||
@@ -47,11 +51,38 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
setFormData({
|
setFormData({
|
||||||
name: project.name || "",
|
name: project.name || "",
|
||||||
description: project.description || "",
|
description: project.description || "",
|
||||||
color: project.color || "#3B82F6",
|
color: project.color || "#3B82F6",
|
||||||
client: project.client ? project.client.id : "",
|
client: project.client ? project.client.id : "",
|
||||||
});
|
});
|
||||||
}
|
setThumbnailUrl(project.thumbnail || null);
|
||||||
}, [project]);
|
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) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -64,6 +95,8 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
color: formData.color,
|
color: formData.color,
|
||||||
client: formData.client || null,
|
client: formData.client || null,
|
||||||
|
thumbnail: thumbnailFile,
|
||||||
|
clear_thumbnail: clearThumbnail,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
toast.success(t.projects?.updateSuccess || "Project updated successfully.");
|
||||||
@@ -164,7 +197,37 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{t.projects?.descriptionLabel || 'Description'}
|
{t.projects?.descriptionLabel || 'Description'}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ export default function Clients() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center 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">
|
<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>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
|
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
|
||||||
|
|||||||
@@ -374,9 +374,15 @@ export const Projects: React.FC = () => {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div
|
<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" }}
|
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">
|
<div className="min-w-0">
|
||||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
|
<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">
|
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Client {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
created_by?: AuditUser | null;
|
created_by?: AuditUser | null;
|
||||||
can_delete: boolean;
|
can_delete: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user