Files
brain-dump/components/TaskDetailModal.tsx
T

460 lines
20 KiB
TypeScript
Raw Normal View History

2026-01-02 17:51:55 +01:00
"use client";
import { CATEGORIES, STATUSES } from "@/lib/constants";
import { ChecklistItem, Task, TaskCategory, TaskStatus } from "@/types";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
interface TaskDetailModalProps {
task: Task;
onClose: () => void;
onUpdate: (task: Task) => void;
onDelete: (taskId: number) => void;
onComplete: (taskId: number) => void;
}
export default function TaskDetailModal({
task,
onClose,
onUpdate,
onDelete,
onComplete,
}: TaskDetailModalProps) {
const [title, setTitle] = useState(task.text);
const [description, setDescription] = useState(task.description || "");
const [checklist, setChecklist] = useState<ChecklistItem[]>(
task.checklist || []
);
const [newItemText, setNewItemText] = useState("");
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [saving, setSaving] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const cat = CATEGORIES.find((c) => c.key === task.category);
const autoSave = useCallback(
async (updates: Partial<Task>) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
setSaving(true);
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: task.id, ...updates }),
});
if (response.ok) {
const updatedTask = await response.json();
onUpdate(updatedTask);
}
} catch (error) {
console.error("Error saving:", error);
} finally {
setSaving(false);
}
}, 500);
},
[task.id, onUpdate]
);
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
if (newTitle.trim()) {
autoSave({ text: newTitle });
}
};
const handleDescriptionChange = (newDescription: string) => {
setDescription(newDescription);
autoSave({ description: newDescription });
};
const addChecklistItem = () => {
if (!newItemText.trim()) return;
const newItem: ChecklistItem = {
id: Date.now().toString(),
text: newItemText,
checked: false,
};
const newChecklist = [...checklist, newItem];
setChecklist(newChecklist);
setNewItemText("");
autoSave({ checklist: newChecklist });
};
const toggleChecklistItem = (itemId: string) => {
const newChecklist = checklist.map((item) =>
item.id === itemId ? { ...item, checked: !item.checked } : item
);
setChecklist(newChecklist);
autoSave({ checklist: newChecklist });
};
const deleteChecklistItem = (itemId: string) => {
const newChecklist = checklist.filter((item) => item.id !== itemId);
setChecklist(newChecklist);
autoSave({ checklist: newChecklist });
};
const updateChecklistItemText = (itemId: string, newText: string) => {
const newChecklist = checklist.map((item) =>
item.id === itemId ? { ...item, text: newText } : item
);
setChecklist(newChecklist);
autoSave({ checklist: newChecklist });
};
const handleCategoryChange = async (newCategory: TaskCategory) => {
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: task.id, category: newCategory }),
});
if (response.ok) {
const updatedTask = await response.json();
onUpdate(updatedTask);
}
} catch (error) {
console.error("Error updating category:", error);
}
};
const handleStatusChange = async (newStatus: TaskStatus) => {
try {
const response = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: task.id, status: newStatus }),
});
if (response.ok) {
const updatedTask = await response.json();
onUpdate(updatedTask);
}
} catch (error) {
console.error("Error updating status:", error);
}
};
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [isEditingTitle]);
useEffect(() => {
if (isEditingDescription && descriptionRef.current) {
descriptionRef.current.focus();
}
}, [isEditingDescription]);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
const completedCount = checklist.filter((item) => item.checked).length;
const progressPercent =
checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-start justify-center z-50 p-4 overflow-y-auto"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.95, y: 20 }}
onClick={(e) => e.stopPropagation()}
className="bg-gray-800 rounded-2xl w-full max-w-2xl my-8 overflow-hidden"
>
<div
className={`${cat?.color} p-4 flex justify-between items-start`}
>
<div className="flex-1">
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
value={title}
onChange={(e) =>
handleTitleChange(e.target.value)
}
onBlur={() => setIsEditingTitle(false)}
onKeyDown={(e) => {
if (e.key === "Enter")
setIsEditingTitle(false);
if (e.key === "Escape") {
setTitle(task.text);
setIsEditingTitle(false);
}
}}
className="w-full bg-white/20 text-white text-xl font-bold px-3 py-2 rounded-lg outline-none"
/>
) : (
<h2
onClick={() => setIsEditingTitle(true)}
className="text-xl font-bold text-white cursor-pointer hover:bg-white/10 px-3 py-2 rounded-lg transition"
>
{title}
</h2>
)}
<p className="text-white/70 text-sm mt-1 px-3">
dans {cat?.label} {task.added}
</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white text-2xl p-2"
>
</button>
</div>
{saving && (
<div className="bg-blue-500/20 text-blue-400 text-sm px-4 py-2 flex items-center gap-2">
<span className="animate-spin"></span>{" "}
Enregistrement...
</div>
)}
<div className="p-6 space-y-6">
<div>
<h3 className="text-sm font-medium text-gray-400 mb-2">
Statut
</h3>
<div className="flex gap-2">
{STATUSES.map((status) => (
<button
key={status.key}
onClick={() =>
handleStatusChange(status.key)
}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition ${
task.status === status.key
? `${status.color} text-white`
: "bg-gray-700 hover:bg-gray-600 text-gray-300"
}`}
>
{status.key === "done" ? "✓ " : ""}
{status.label}
</button>
))}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-gray-400 mb-2">
Catégorie
</h3>
<div className="flex gap-2 flex-wrap">
{CATEGORIES.map((category) => (
<button
key={category.key}
onClick={() =>
handleCategoryChange(category.key)
}
className={`py-2 px-4 rounded-lg text-sm font-medium transition ${
task.category === category.key
? `${category.color} text-white`
: "bg-gray-700 hover:bg-gray-600 text-gray-300"
}`}
>
{category.label}
</button>
))}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-gray-400 mb-2">
📝 Description
</h3>
{isEditingDescription ? (
<textarea
ref={descriptionRef}
value={description}
onChange={(e) =>
handleDescriptionChange(e.target.value)
}
onBlur={() => setIsEditingDescription(false)}
placeholder="Ajoute une description plus détaillée..."
className="w-full bg-gray-700 text-white p-4 rounded-xl outline-none resize-none min-h-30 focus:ring-2 focus:ring-blue-500"
/>
) : (
<div
onClick={() => setIsEditingDescription(true)}
className={`w-full bg-gray-700 p-4 rounded-xl cursor-pointer hover:bg-gray-600 transition min-h-20 ${
description ? "text-white" : "text-gray-500"
}`}
>
{description ||
"Ajoute une description plus détaillée..."}
</div>
)}
</div>
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-medium text-gray-400">
☑️ Checklist
</h3>
{checklist.length > 0 && (
<span className="text-sm text-gray-500">
{completedCount}/{checklist.length}
</span>
)}
</div>
{checklist.length > 0 && (
<div className="h-2 bg-gray-700 rounded-full mb-4 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progressPercent}%` }}
className="h-full bg-green-500"
/>
</div>
)}
<div className="space-y-2 mb-4">
<AnimatePresence>
{checklist.map((item) => (
<motion.div
key={item.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex items-center gap-3 group"
>
<button
onClick={() =>
toggleChecklistItem(item.id)
}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition ${
item.checked
? "bg-green-500 border-green-500 text-white"
: "border-gray-500 hover:border-gray-400"
}`}
>
{item.checked && "✓"}
</button>
<input
type="text"
value={item.text}
onChange={(e) =>
updateChecklistItemText(
item.id,
e.target.value
)
}
className={`flex-1 bg-transparent outline-none ${
item.checked
? "text-gray-500 line-through"
: "text-white"
}`}
/>
<button
onClick={() =>
deleteChecklistItem(item.id)
}
className="text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition"
>
</button>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="flex gap-2">
<input
type="text"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addChecklistItem();
}}
placeholder="Ajouter un élément..."
className="flex-1 bg-gray-700 text-white px-4 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={addChecklistItem}
disabled={!newItemText.trim()}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition"
>
+
</button>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-700">
{task.status !== "done" &&
task.status !== "archived" && (
<button
onClick={async () => {
await handleStatusChange("done");
onClose();
}}
className="flex-1 bg-green-500 hover:bg-green-600 text-white py-3 rounded-xl font-bold transition"
>
Terminer
</button>
)}
{task.status === "done" && (
<button
onClick={() => {
onComplete(task.id);
onClose();
}}
className="flex-1 bg-slate-600 hover:bg-slate-700 text-white py-3 rounded-xl font-bold transition"
>
📦 Archiver
</button>
)}
{task.status === "done" && (
<button
onClick={async () => {
await handleStatusChange("todo");
onClose();
}}
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white py-3 rounded-xl font-bold transition"
>
↩️ Réouvrir
</button>
)}
<button
onClick={() => {
onDelete(task.id);
onClose();
}}
className="bg-red-500/20 hover:bg-red-500/30 text-red-400 px-6 py-3 rounded-xl font-bold transition"
>
🗑️
</button>
</div>
</div>
</motion.div>
</motion.div>
);
}