566 lines
17 KiB
TypeScript
566 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
DragOverlay,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
Check,
|
|
Edit,
|
|
GripVertical,
|
|
MoreVertical,
|
|
Plus,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Item,
|
|
ItemActions,
|
|
ItemContent,
|
|
ItemGroup,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface Todo {
|
|
id: string;
|
|
title: string;
|
|
completed: boolean;
|
|
}
|
|
|
|
interface Todo5Props {
|
|
heading?: string;
|
|
description?: string;
|
|
addTaskLabel?: string;
|
|
taskPlaceholder?: string;
|
|
cancelLabel?: string;
|
|
submitLabel?: string;
|
|
editLabel?: string;
|
|
deleteLabel?: string;
|
|
saveEditLabel?: string;
|
|
cancelEditLabel?: string;
|
|
moreActionsLabel?: string;
|
|
editFieldLabel?: string;
|
|
deleteDialogTitle?: string;
|
|
deleteDialogDescription?: string;
|
|
deleteConfirmLabel?: string;
|
|
initialTodos?: Todo[];
|
|
className?: string;
|
|
}
|
|
|
|
const defaultTodos: Todo[] = [
|
|
{
|
|
id: "1",
|
|
title: "تکمیل مستندات فنی پروژه",
|
|
completed: false,
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "بررسی و تأیید طراحی رابط کاربری",
|
|
completed: false,
|
|
},
|
|
{
|
|
id: "3",
|
|
title: "آمادهسازی گزارش هفتگی",
|
|
completed: false,
|
|
},
|
|
];
|
|
|
|
interface SortableTodoItemProps {
|
|
todo: Todo;
|
|
onToggle: (id: string) => void;
|
|
editingTodo: string | null;
|
|
editTitle: string;
|
|
onStartEdit: (todo: Todo) => void;
|
|
onSaveEdit: () => void;
|
|
onCancelEdit: () => void;
|
|
onEditTitleChange: (title: string) => void;
|
|
onDeleteClick: (id: string) => void;
|
|
editLabel: string;
|
|
deleteLabel: string;
|
|
saveEditLabel: string;
|
|
cancelEditLabel: string;
|
|
moreActionsLabel: string;
|
|
editFieldLabel: string;
|
|
}
|
|
|
|
const SortableTodoItem = ({
|
|
todo,
|
|
onToggle,
|
|
editingTodo,
|
|
editTitle,
|
|
onStartEdit,
|
|
onSaveEdit,
|
|
onCancelEdit,
|
|
onEditTitleChange,
|
|
onDeleteClick,
|
|
editLabel,
|
|
deleteLabel,
|
|
saveEditLabel,
|
|
cancelEditLabel,
|
|
moreActionsLabel,
|
|
editFieldLabel,
|
|
}: SortableTodoItemProps) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: todo.id });
|
|
|
|
const style = {
|
|
transform: CSS.Translate.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style}>
|
|
<Item
|
|
variant={todo.completed ? "muted" : "default"}
|
|
className={cn(
|
|
"group cursor-pointer items-center border-b transition-opacity",
|
|
todo.completed && "opacity-60",
|
|
)}
|
|
onClick={() => onToggle(todo.id)}
|
|
>
|
|
<ItemMedia>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
{...attributes}
|
|
{...listeners}
|
|
className="cursor-grab active:cursor-grabbing"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={`جابهجایی «${todo.title}»`}
|
|
>
|
|
<GripVertical className="size-4 text-muted-foreground" />
|
|
</Button>
|
|
<Checkbox
|
|
checked={todo.completed}
|
|
onCheckedChange={() => onToggle(todo.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={
|
|
todo.completed
|
|
? `علامتگذاری «${todo.title}» بهعنوان انجامنشده`
|
|
: `علامتگذاری «${todo.title}» بهعنوان انجامشده`
|
|
}
|
|
/>
|
|
</div>
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
{editingTodo === todo.id ? (
|
|
<Input
|
|
value={editTitle}
|
|
onChange={(e) => onEditTitleChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
onSaveEdit();
|
|
}
|
|
if (e.key === "Escape") {
|
|
onCancelEdit();
|
|
}
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-base font-medium"
|
|
autoFocus
|
|
aria-label={editFieldLabel}
|
|
/>
|
|
) : (
|
|
<ItemTitle
|
|
className={
|
|
todo.completed ? "text-muted-foreground line-through" : ""
|
|
}
|
|
>
|
|
{todo.title}
|
|
</ItemTitle>
|
|
)}
|
|
</ItemContent>
|
|
<ItemActions>
|
|
{editingTodo === todo.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSaveEdit();
|
|
}}
|
|
aria-label={saveEditLabel}
|
|
>
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCancelEdit();
|
|
}}
|
|
aria-label={cancelEditLabel}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={moreActionsLabel}
|
|
>
|
|
<MoreVertical className="h-4 w-4" aria-hidden />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onStartEdit(todo);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" aria-hidden />
|
|
{editLabel}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDeleteClick(todo.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
{deleteLabel}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</ItemActions>
|
|
</Item>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Todo5 = ({
|
|
heading = "مدیریت وظایف با ویرایش و حذف",
|
|
description =
|
|
"وظیفه جدید اضافه کنید، ترتیب را تغییر دهید، ویرایش کنید یا حذف کنید.",
|
|
addTaskLabel = "افزودن وظیفه جدید",
|
|
taskPlaceholder = "عنوان وظیفه",
|
|
cancelLabel = "انصراف",
|
|
submitLabel = "افزودن",
|
|
editLabel = "ویرایش",
|
|
deleteLabel = "حذف",
|
|
saveEditLabel = "ذخیره",
|
|
cancelEditLabel = "لغو",
|
|
moreActionsLabel = "گزینههای بیشتر",
|
|
editFieldLabel = "ویرایش عنوان وظیفه",
|
|
deleteDialogTitle = "حذف وظیفه؟",
|
|
deleteDialogDescription =
|
|
"این وظیفه برای همیشه حذف میشود و قابل بازگشت نیست.",
|
|
deleteConfirmLabel = "حذف",
|
|
initialTodos = defaultTodos,
|
|
className,
|
|
}: Todo5Props) => {
|
|
const [todos, setTodos] = useState(initialTodos);
|
|
const [isFormExpanded, setIsFormExpanded] = useState(false);
|
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
|
const [editingTodo, setEditingTodo] = useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = useState("");
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [todoToDelete, setTodoToDelete] = useState<string | null>(null);
|
|
const [activeTodo, setActiveTodo] = useState<Todo | null>(null);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
const toggleTodo = (id: string) => {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.map((todo) =>
|
|
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
|
|
),
|
|
);
|
|
};
|
|
|
|
const handleAddTask = () => {
|
|
if (newTaskTitle.trim()) {
|
|
const newTodo: Todo = {
|
|
id: Date.now().toString(),
|
|
title: newTaskTitle.trim(),
|
|
completed: false,
|
|
};
|
|
setTodos((prevTodos) => [...prevTodos, newTodo]);
|
|
setNewTaskTitle("");
|
|
setIsFormExpanded(false);
|
|
}
|
|
};
|
|
|
|
const handleStartEdit = (todo: Todo) => {
|
|
setEditingTodo(todo.id);
|
|
setEditTitle(todo.title);
|
|
};
|
|
|
|
const handleSaveEdit = () => {
|
|
if (editingTodo && editTitle.trim()) {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.map((todo) =>
|
|
todo.id === editingTodo ? { ...todo, title: editTitle.trim() } : todo,
|
|
),
|
|
);
|
|
setEditingTodo(null);
|
|
setEditTitle("");
|
|
}
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setEditingTodo(null);
|
|
setEditTitle("");
|
|
};
|
|
|
|
const handleDeleteClick = (id: string) => {
|
|
setTodoToDelete(id);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = () => {
|
|
if (todoToDelete) {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.filter((todo) => todo.id !== todoToDelete),
|
|
);
|
|
setDeleteDialogOpen(false);
|
|
setTodoToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
const { active } = event;
|
|
const todo = todos.find((t) => t.id === active.id);
|
|
setActiveTodo(todo || null);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (over && active.id !== over.id) {
|
|
setTodos((items) => {
|
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
|
return arrayMove(items, oldIndex, newIndex);
|
|
});
|
|
}
|
|
|
|
setActiveTodo(null);
|
|
};
|
|
|
|
return (
|
|
<section dir="rtl" lang="fa" className={cn("py-32", className)}>
|
|
<div className="container">
|
|
<div className="mx-auto flex max-w-2xl flex-col gap-8">
|
|
<div className="flex flex-col gap-2">
|
|
<h2 className="text-3xl font-semibold md:text-xl">{heading}</h2>
|
|
<p className="text-muted-foreground">{description}</p>
|
|
</div>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="flex max-w-2xl flex-col gap-4">
|
|
{!isFormExpanded ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="lg"
|
|
className="w-full justify-start gap-2"
|
|
onClick={() => setIsFormExpanded(true)}
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
{addTaskLabel}
|
|
</Button>
|
|
) : (
|
|
<div className="flex flex-col gap-2 rounded-lg border p-4">
|
|
<Input
|
|
autoFocus
|
|
placeholder={taskPlaceholder}
|
|
value={newTaskTitle}
|
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && newTaskTitle.trim()) {
|
|
handleAddTask();
|
|
}
|
|
if (e.key === "Escape") {
|
|
setNewTaskTitle("");
|
|
setIsFormExpanded(false);
|
|
}
|
|
}}
|
|
className="text-base font-medium"
|
|
aria-label={taskPlaceholder}
|
|
/>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setNewTaskTitle("");
|
|
setIsFormExpanded(false);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden />
|
|
{cancelLabel}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAddTask}
|
|
disabled={!newTaskTitle.trim()}
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
{submitLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<SortableContext
|
|
items={todos.map((t) => t.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<ItemGroup>
|
|
{todos.map((todo) => (
|
|
<SortableTodoItem
|
|
key={todo.id}
|
|
todo={todo}
|
|
onToggle={toggleTodo}
|
|
editingTodo={editingTodo}
|
|
editTitle={editTitle}
|
|
onStartEdit={handleStartEdit}
|
|
onSaveEdit={handleSaveEdit}
|
|
onCancelEdit={handleCancelEdit}
|
|
onEditTitleChange={setEditTitle}
|
|
onDeleteClick={handleDeleteClick}
|
|
editLabel={editLabel}
|
|
deleteLabel={deleteLabel}
|
|
saveEditLabel={saveEditLabel}
|
|
cancelEditLabel={cancelEditLabel}
|
|
moreActionsLabel={moreActionsLabel}
|
|
editFieldLabel={editFieldLabel}
|
|
/>
|
|
))}
|
|
</ItemGroup>
|
|
</SortableContext>
|
|
</div>
|
|
<DragOverlay>
|
|
{activeTodo ? (
|
|
<Item
|
|
variant={activeTodo.completed ? "muted" : "default"}
|
|
className="cursor-grabbing items-center border-b opacity-90 shadow-lg"
|
|
>
|
|
<ItemMedia>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="cursor-grabbing"
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
>
|
|
<GripVertical className="size-4 text-muted-foreground" />
|
|
</Button>
|
|
<Checkbox
|
|
checked={activeTodo.completed}
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
/>
|
|
</div>
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle
|
|
className={
|
|
activeTodo.completed
|
|
? "text-muted-foreground line-through"
|
|
: ""
|
|
}
|
|
>
|
|
{activeTodo.title}
|
|
</ItemTitle>
|
|
</ItemContent>
|
|
</Item>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent dir="rtl" lang="fa" className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{deleteDialogTitle}</DialogTitle>
|
|
<DialogDescription>{deleteDialogDescription}</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
>
|
|
{cancelLabel}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
|
{deleteConfirmLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { Todo5 };
|