feat(media): manage client and project thumbnails
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user