496 lines
15 KiB
TypeScript
496 lines
15 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 { ChevronDown, ChevronRight, GripVertical, Plus } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemGroup,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface Subtask {
|
|
id: string;
|
|
title: string;
|
|
completed: boolean;
|
|
}
|
|
|
|
interface Todo {
|
|
id: string;
|
|
title: string;
|
|
completed: boolean;
|
|
subtasks: Subtask[];
|
|
expanded?: boolean;
|
|
}
|
|
|
|
interface SubtaskLabels {
|
|
placeholder: string;
|
|
addButton: string;
|
|
submit: string;
|
|
cancel: string;
|
|
expand: string;
|
|
collapse: string;
|
|
}
|
|
|
|
interface Todo8Props {
|
|
heading?: string;
|
|
description?: string;
|
|
subtaskLabels?: SubtaskLabels;
|
|
initialTodos?: Todo[];
|
|
className?: string;
|
|
}
|
|
|
|
const defaultSubtaskLabels: SubtaskLabels = {
|
|
placeholder: "عنوان وظیفه فرعی",
|
|
addButton: "افزودن وظیفه فرعی",
|
|
submit: "افزودن",
|
|
cancel: "انصراف",
|
|
expand: "نمایش وظایف فرعی",
|
|
collapse: "پنهان کردن وظایف فرعی",
|
|
};
|
|
|
|
const defaultTodos: Todo[] = [
|
|
{
|
|
id: "1",
|
|
title: "برنامهریزی راهاندازی پروژه",
|
|
completed: false,
|
|
expanded: true,
|
|
subtasks: [
|
|
{ id: "1-1", title: "تدوین جدول زمانی پروژه", completed: true },
|
|
{ id: "1-2", title: "هماهنگی جلسات تیم", completed: true },
|
|
{ id: "1-3", title: "آمادهسازی ارائه", completed: false },
|
|
{ id: "1-4", title: "بررسی بودجه", completed: false },
|
|
],
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "بررسی و تأیید طراحی رابط کاربری",
|
|
completed: false,
|
|
expanded: false,
|
|
subtasks: [
|
|
{ id: "2-1", title: "طراحی وایرفریم", completed: false },
|
|
{ id: "2-2", title: "دریافت تأیید ذینفعان", completed: false },
|
|
],
|
|
},
|
|
{
|
|
id: "3",
|
|
title: "نوشتن مستندات فنی",
|
|
completed: false,
|
|
expanded: false,
|
|
subtasks: [],
|
|
},
|
|
];
|
|
|
|
interface SortableTodoItemProps {
|
|
todo: Todo;
|
|
onToggle: (id: string) => void;
|
|
onToggleSubtask: (todoId: string, subtaskId: string) => void;
|
|
onToggleExpand: (id: string) => void;
|
|
onAddSubtask: (todoId: string, title: string) => void;
|
|
subtaskLabels: SubtaskLabels;
|
|
}
|
|
|
|
const SortableTodoItem = ({
|
|
todo,
|
|
onToggle,
|
|
onToggleSubtask,
|
|
onToggleExpand,
|
|
onAddSubtask,
|
|
subtaskLabels,
|
|
}: 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,
|
|
};
|
|
|
|
const completedSubtasks = todo.subtasks.filter((st) => st.completed).length;
|
|
const totalSubtasks = todo.subtasks.length;
|
|
const progress =
|
|
totalSubtasks > 0
|
|
? Math.round((completedSubtasks / totalSubtasks) * 100)
|
|
: 0;
|
|
|
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
|
const [isAddingSubtask, setIsAddingSubtask] = useState(false);
|
|
|
|
const handleAddSubtask = () => {
|
|
if (newSubtaskTitle.trim()) {
|
|
onAddSubtask(todo.id, newSubtaskTitle.trim());
|
|
setNewSubtaskTitle("");
|
|
setIsAddingSubtask(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style}>
|
|
<Item
|
|
variant={todo.completed ? "muted" : "default"}
|
|
className={cn(
|
|
"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>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleExpand(todo.id);
|
|
}}
|
|
aria-label={todo.expanded ? subtaskLabels.collapse : subtaskLabels.expand}
|
|
aria-expanded={todo.expanded}
|
|
>
|
|
{todo.expanded ? (
|
|
<ChevronDown className="size-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="size-4 text-muted-foreground rtl:rotate-180" />
|
|
)}
|
|
</Button>
|
|
<Checkbox
|
|
checked={todo.completed}
|
|
onCheckedChange={() => onToggle(todo.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={
|
|
todo.completed
|
|
? `علامتگذاری «${todo.title}» بهعنوان انجامنشده`
|
|
: `علامتگذاری «${todo.title}» بهعنوان انجامشده`
|
|
}
|
|
/>
|
|
</div>
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<div className="flex items-center gap-2">
|
|
<ItemTitle
|
|
className={
|
|
todo.completed ? "text-muted-foreground line-through" : ""
|
|
}
|
|
>
|
|
{todo.title}
|
|
</ItemTitle>
|
|
{totalSubtasks > 0 && (
|
|
<div
|
|
className="flex items-center gap-1 text-xs text-muted-foreground"
|
|
aria-label={`پیشرفت «${todo.title}»: ${completedSubtasks} از ${totalSubtasks}`}
|
|
>
|
|
<span className="font-medium">
|
|
{completedSubtasks}/{totalSubtasks}
|
|
</span>
|
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full bg-primary transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
{todo.expanded && (
|
|
<div className="border-b bg-muted/30 ps-12">
|
|
<div className="flex flex-col">
|
|
{todo.subtasks.map((subtask) => (
|
|
<div
|
|
key={subtask.id}
|
|
className="flex items-center gap-2 border-b px-4 py-2 last:border-b-0 hover:bg-muted/50"
|
|
>
|
|
<Checkbox
|
|
checked={subtask.completed}
|
|
onCheckedChange={() => onToggleSubtask(todo.id, subtask.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={
|
|
subtask.completed
|
|
? `علامتگذاری «${subtask.title}» بهعنوان انجامنشده`
|
|
: `علامتگذاری «${subtask.title}» بهعنوان انجامشده`
|
|
}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"flex-1 text-sm",
|
|
subtask.completed && "text-muted-foreground line-through",
|
|
)}
|
|
>
|
|
{subtask.title}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{isAddingSubtask ? (
|
|
<div className="flex items-center gap-2 px-4 py-2">
|
|
<Input
|
|
autoFocus
|
|
placeholder={subtaskLabels.placeholder}
|
|
value={newSubtaskTitle}
|
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleAddSubtask();
|
|
}
|
|
if (e.key === "Escape") {
|
|
setIsAddingSubtask(false);
|
|
setNewSubtaskTitle("");
|
|
}
|
|
}}
|
|
className="h-8"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={subtaskLabels.placeholder}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleAddSubtask();
|
|
}}
|
|
>
|
|
{subtaskLabels.submit}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsAddingSubtask(false);
|
|
setNewSubtaskTitle("");
|
|
}}
|
|
>
|
|
{subtaskLabels.cancel}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="justify-start gap-2 px-4 py-2"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsAddingSubtask(true);
|
|
}}
|
|
>
|
|
<Plus className="size-4" aria-hidden />
|
|
{subtaskLabels.addButton}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Todo8 = ({
|
|
heading = "مدیریت وظایف با زیر وظایف",
|
|
description =
|
|
"وظایف تاشو با زیروظایف تو در تو، نمایش پیشرفت و مرتبسازی با کشیدن. زیروظایف یا وظیفه اصلی را جداگانه تکمیل کنید.",
|
|
subtaskLabels = defaultSubtaskLabels,
|
|
initialTodos = defaultTodos,
|
|
className,
|
|
}: Todo8Props) => {
|
|
const [todos, setTodos] = useState(initialTodos);
|
|
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 toggleSubtask = (todoId: string, subtaskId: string) => {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.map((todo) =>
|
|
todo.id === todoId
|
|
? {
|
|
...todo,
|
|
subtasks: todo.subtasks.map((st) =>
|
|
st.id === subtaskId ? { ...st, completed: !st.completed } : st,
|
|
),
|
|
}
|
|
: todo,
|
|
),
|
|
);
|
|
};
|
|
|
|
const toggleExpand = (id: string) => {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.map((todo) =>
|
|
todo.id === id ? { ...todo, expanded: !todo.expanded } : todo,
|
|
),
|
|
);
|
|
};
|
|
|
|
const addSubtask = (todoId: string, title: string) => {
|
|
setTodos((prevTodos) =>
|
|
prevTodos.map((todo) =>
|
|
todo.id === todoId
|
|
? {
|
|
...todo,
|
|
subtasks: [
|
|
...todo.subtasks,
|
|
{ id: `${todoId}-${Date.now()}`, title, completed: false },
|
|
],
|
|
expanded: true,
|
|
}
|
|
: todo,
|
|
),
|
|
);
|
|
};
|
|
|
|
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) {
|
|
const oldIndex = todos.findIndex((item) => item.id === active.id);
|
|
const newIndex = todos.findIndex((item) => item.id === over.id);
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1) {
|
|
setTodos((items) => arrayMove(items, oldIndex, newIndex));
|
|
}
|
|
}
|
|
|
|
setActiveTodo(null);
|
|
};
|
|
|
|
const allTodoIds = useMemo(() => todos.map((t) => t.id), [todos]);
|
|
|
|
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">
|
|
<SortableContext
|
|
items={allTodoIds}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<ItemGroup>
|
|
{todos.map((todo) => (
|
|
<SortableTodoItem
|
|
key={todo.id}
|
|
todo={todo}
|
|
onToggle={toggleTodo}
|
|
onToggleSubtask={toggleSubtask}
|
|
onToggleExpand={toggleExpand}
|
|
onAddSubtask={addSubtask}
|
|
subtaskLabels={subtaskLabels}
|
|
/>
|
|
))}
|
|
</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>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { Todo8 };
|