Files
reui-blocks/blocks/todo10.tsx
T

1528 lines
47 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 {
ArrowDown,
ArrowUp,
Calendar,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
Circle,
Copy,
Flag,
GripVertical,
List,
MoreVertical,
Move,
Plus,
Puzzle,
SendHorizonal,
Star,
Trash2,
X,
} from "lucide-react";
import { useMemo, 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 {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const formatDueDate = (date: Date) =>
date.toLocaleDateString("fa-IR", { month: "short", day: "numeric" });
interface Todo10Labels {
moreActions: string;
addTaskAbove: string;
addTaskBelow: string;
dateSection: string;
prioritySection: string;
today: string;
tomorrow: string;
nextWeek: string;
nextMonth: string;
noDate: string;
moveTo: string;
duplicate: string;
copyTaskLink: string;
addExtension: string;
removeFavorite: string;
addFavorite: string;
delete: string;
cancel: string;
deleteDialogTitle: string;
deleteDialogDescription: (title: string) => string;
addTaskLabel: string;
taskPlaceholder: string;
dateLabel: string;
priorityLabel: string;
noPriority: string;
selectProject: string;
submitTask: string;
emptyTitle: string;
emptyDescription: string;
allTasks: (count: number) => string;
completedLabel: string;
expandSubtasks: string;
collapseSubtasks: string;
dragTask: (title: string) => string;
markIncomplete: (title: string) => string;
markComplete: (title: string) => string;
}
const defaultLabels: Todo10Labels = {
moreActions: "گزینه‌های بیشتر",
addTaskAbove: "افزودن وظیفه بالا",
addTaskBelow: "افزودن وظیفه پایین",
dateSection: "تاریخ",
prioritySection: "اولویت",
today: "امروز",
tomorrow: "فردا",
nextWeek: "هفته آینده",
nextMonth: "ماه آینده",
noDate: "بدون تاریخ",
moveTo: "انتقال به…",
duplicate: "تکثیر",
copyTaskLink: "کپی لینک وظیفه",
addExtension: "افزودن افزونه…",
removeFavorite: "حذف از علاقه‌مندی‌ها",
addFavorite: "افزودن به علاقه‌مندی‌ها",
delete: "حذف",
cancel: "انصراف",
deleteDialogTitle: "حذف وظیفه؟",
deleteDialogDescription: (title) =>
`وظیفه «${title}» برای همیشه حذف می‌شود.`,
addTaskLabel: "افزودن وظیفه جدید",
taskPlaceholder: "عنوان وظیفه",
dateLabel: "تاریخ",
priorityLabel: "اولویت",
noPriority: "بدون اولویت",
selectProject: "انتخاب پروژه",
submitTask: "افزودن",
emptyTitle: "هنوز وظیفه‌ای ندارید",
emptyDescription: "با افزودن اولین وظیفه خود شروع کنید.",
allTasks: (count) => `همه وظایف (${count})`,
completedLabel: "تکمیل‌شده",
expandSubtasks: "نمایش زیروظایف",
collapseSubtasks: "پنهان کردن زیروظایف",
dragTask: (title) => `جابه‌جایی «${title}»`,
markIncomplete: (title) => `علامت‌گذاری «${title}» به‌عنوان انجام‌نشده`,
markComplete: (title) => `علامت‌گذاری «${title}» به‌عنوان انجام‌شده`,
};
interface Todo10Props {
projects?: Project[];
initialTodos?: Todo[];
labels?: Todo10Labels;
className?: string;
}
type Priority = "P1" | "P2" | "P3" | "P4";
type SortOption =
| "manual"
| "dueDate"
| "priority"
| "title"
| "createdAt"
| "updatedAt";
interface Subtask {
id: string;
title: string;
completed: boolean;
}
interface Project {
id: string;
name: string;
color?: string;
}
interface Todo {
id: string;
title: string;
completed: boolean;
projectId: string;
priority?: Priority;
dueDate?: Date;
starred?: boolean;
subtasks: Subtask[];
createdAt: Date;
updatedAt: Date;
}
interface TodoFilters {
projects: string[];
priorities: string[];
starredOnly: boolean;
}
const defaultProjects: Project[] = [
{ id: "personal", name: "کارهای شخصی", color: "#3b82f6" },
{ id: "work", name: "پروژه‌های کاری", color: "#8b5cf6" },
{ id: "shopping", name: "خرید", color: "#10b981" },
{ id: "learning", name: "یادگیری", color: "#ec4899" },
];
const defaultTodos: Todo[] = [
{
id: "1",
title: "تکمیل مستندات فنی پروژه",
completed: false,
projectId: "work",
priority: "P1",
dueDate: new Date(2025, 9, 25),
starred: true,
subtasks: [
{ id: "s1", title: "نوشتن مستندات فنی", completed: true },
{ id: "s2", title: "افزودن نمونه کد", completed: false },
],
createdAt: new Date(2025, 9, 15),
updatedAt: new Date(2025, 9, 20),
},
{
id: "2",
title: "بررسی و تأیید طراحی رابط کاربری",
completed: false,
projectId: "work",
priority: "P2",
subtasks: [],
createdAt: new Date(2025, 9, 18),
updatedAt: new Date(2025, 9, 20),
},
{
id: "3",
title: "آماده‌سازی گزارش هفتگی",
completed: false,
projectId: "shopping",
priority: "P3",
subtasks: [],
createdAt: new Date(2025, 9, 20),
updatedAt: new Date(2025, 9, 20),
},
{
id: "4",
title: "هماهنگی جلسات تیم",
completed: true,
projectId: "personal",
priority: "P2",
subtasks: [],
createdAt: new Date(2024, 9, 19),
updatedAt: new Date(2024, 9, 23),
},
{
id: "5",
title: "بررسی مستندات فنی",
completed: false,
projectId: "learning",
priority: "P4",
starred: true,
subtasks: [],
createdAt: new Date(2025, 9, 16),
updatedAt: new Date(2025, 9, 20),
},
{
id: "6",
title: "آماده‌سازی ارائه جلسه تیم",
completed: false,
projectId: "work",
priority: "P2",
dueDate: new Date(2025, 10, 5),
subtasks: [
{ id: "s3", title: "طراحی اسلایدها", completed: false },
{ id: "s4", title: "آماده‌سازی محیط دمو", completed: false },
],
createdAt: new Date(2025, 9, 30),
updatedAt: new Date(2025, 9, 30),
},
{
id: "7",
title: "برنامه‌ریزی جلسه حضوری تیمی",
completed: false,
projectId: "personal",
priority: "P3",
dueDate: new Date(2025, 10, 8),
starred: true,
subtasks: [
{ id: "s5", title: "بررسی آب‌وهوا", completed: false },
{ id: "s6", title: "هماهنگی مکان", completed: false },
],
createdAt: new Date(2025, 9, 30),
updatedAt: new Date(2025, 9, 30),
},
{
id: "8",
title: "بازبینی pull request شماره ۲۳۴",
completed: true,
projectId: "work",
priority: "P2",
subtasks: [],
createdAt: new Date(2024, 9, 22),
updatedAt: new Date(2024, 9, 23),
},
{
id: "9",
title: "به‌روزرسانی مستندات فنی",
completed: true,
projectId: "work",
priority: "P3",
subtasks: [],
createdAt: new Date(2024, 9, 22),
updatedAt: new Date(2024, 9, 23),
},
{
id: "10",
title: "رفع مشکلات طراحی واکنش‌گرا",
completed: true,
projectId: "work",
priority: "P1",
subtasks: [],
createdAt: new Date(2024, 9, 21),
updatedAt: new Date(2024, 9, 22),
},
];
const priorityColors: Record<Priority, string> = {
P1: "text-red-500",
P2: "text-orange-500",
P3: "text-yellow-500",
P4: "text-gray-500",
};
const priorityLabels: Record<Priority, string> = {
P1: "P1 - فوری",
P2: "P2 - زیاد",
P3: "P3 - متوسط",
P4: "P4 - کم",
};
function sortTodos(todos: Todo[], sortBy: SortOption): Todo[] {
if (sortBy === "manual") return todos;
return [...todos].sort((a, b) => {
switch (sortBy) {
case "dueDate":
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
case "priority": {
const priorityOrder = { P1: 1, P2: 2, P3: 3, P4: 4 };
const aPriority = a.priority ? priorityOrder[a.priority] : 5;
const bPriority = b.priority ? priorityOrder[b.priority] : 5;
return aPriority - bPriority;
}
case "title":
return a.title.localeCompare(b.title, "fa");
case "createdAt":
return (
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
case "updatedAt":
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
default:
return 0;
}
});
}
function filterTodos(todos: Todo[], filters: TodoFilters): Todo[] {
let result = [...todos];
if (filters.starredOnly) {
result = result.filter((todo) => todo.starred);
}
if (filters.projects.length > 0) {
result = result.filter((todo) => filters.projects.includes(todo.projectId));
}
if (filters.priorities.length > 0) {
result = result.filter((todo) =>
todo.priority ? filters.priorities.includes(todo.priority) : false,
);
}
return result;
}
interface TodoActionsDropdownProps {
todo: Todo;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onDelete?: (id: string) => void;
onUpdate?: (todo: Todo) => void;
onToggleStar?: (id: string) => void;
labels: Todo10Labels;
}
const TodoActionsDropdown = ({
todo,
isOpen,
onOpenChange,
onDelete,
onUpdate,
onToggleStar,
labels,
}: TodoActionsDropdownProps) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const dateOptions = [
{ type: "today" as const, label: labels.today, color: "text-blue-500" },
{ type: "tomorrow" as const, label: labels.tomorrow, color: "text-green-500" },
{ type: "next-week" as const, label: labels.nextWeek, color: "text-cyan-500" },
{
type: "next-month" as const,
label: labels.nextMonth,
color: "text-orange-500",
},
{ type: "no-date" as const, label: labels.noDate, color: "text-yellow-500" },
];
const getPresetDate = (
type: "today" | "tomorrow" | "next-week" | "next-month" | "no-date",
): Date | undefined => {
const now = new Date();
switch (type) {
case "today":
return now;
case "tomorrow": {
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow;
}
case "next-week": {
const nextWeek = new Date(now);
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek;
}
case "next-month": {
const nextMonth = new Date(now);
nextMonth.setMonth(nextMonth.getMonth() + 1);
return nextMonth;
}
case "no-date":
return undefined;
}
};
const setDate = (
dateType: "today" | "tomorrow" | "next-week" | "next-month" | "no-date",
) => {
if (!onUpdate) return;
const newDate = getPresetDate(dateType);
onUpdate({ ...todo, dueDate: newDate });
};
const setPriority = (priority: Priority) => {
if (!onUpdate) return;
onUpdate({ ...todo, priority });
};
const handleCopyLink = () => {
const taskUrl = `${window.location.origin}/task/${todo.id}`;
navigator.clipboard.writeText(taskUrl);
};
return (
<>
<DropdownMenu modal={false} open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
}}
aria-label={labels.moreActions}
className={cn(
"-me-2 transition-all duration-200 ease-out",
"-translate-x-2 scale-50 opacity-0",
"group-hover:translate-x-0 group-hover:scale-100 group-hover:opacity-100",
isOpen && "translate-x-0 scale-100 opacity-100",
)}
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onClick={(e) => e.stopPropagation()}
onCloseAutoFocus={(e) => e.preventDefault()}
className="w-64"
>
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<ArrowUp className="size-4" aria-hidden />
{labels.addTaskAbove}
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<ArrowDown className="size-4" aria-hidden />
{labels.addTaskBelow}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{labels.dateSection}</span>
<span className="text-xs text-muted-foreground">T</span>
</div>
<TooltipProvider delayDuration={300}>
<div className="mt-1 flex items-center gap-3">
{dateOptions.map((option) => (
<Tooltip key={option.type}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
setDate(option.type);
}}
className="cursor-pointer rounded p-1 transition-colors hover:bg-accent"
>
<Calendar className={`${option.color} size-4`} />
</Button>
</TooltipTrigger>
<TooltipContent>{option.label}</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
<div className="px-2 py-1.5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{labels.prioritySection}</span>
<span className="text-xs text-muted-foreground">Y</span>
</div>
<TooltipProvider delayDuration={300}>
<div className="mt-1 flex items-center gap-3">
{(["P1", "P2", "P3", "P4"] as Priority[]).map((priority) => (
<Tooltip key={priority}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
setPriority(priority);
}}
>
<Circle
className={cn("size-4", priorityColors[priority])}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{priorityLabels[priority]}</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<Move className="size-4" aria-hidden />
{labels.moveTo}
<span className="ms-auto text-xs text-muted-foreground">V</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<Plus className="size-4" aria-hidden />
{labels.duplicate}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopyLink();
}}
>
<Copy className="size-4" aria-hidden />
{labels.copyTaskLink}
<span className="ms-auto text-xs text-muted-foreground">⇧ ⌘ L</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<Puzzle className="size-4" aria-hidden />
{labels.addExtension}
</DropdownMenuItem>
<DropdownMenuSeparator />
{!todo.completed && (
<>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onToggleStar?.(todo.id);
}}
>
<Star
className={cn(
"size-4",
todo.starred &&
"fill-amber-500 text-amber-500 dark:fill-amber-400 dark:text-amber-400",
)}
/>
{todo.starred ? labels.removeFavorite : labels.addFavorite}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
setShowDeleteDialog(true);
}}
>
<Trash2 className="size-4" aria-hidden />
{labels.delete}
<span className="ms-auto text-xs text-muted-foreground">⌘ ⌫</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent dir="rtl" lang="fa" className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{labels.deleteDialogTitle}</DialogTitle>
<DialogDescription>
{labels.deleteDialogDescription(todo.title)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(false);
}}
>
{labels.cancel}
</Button>
<Button
type="button"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
onDelete?.(todo.id);
setShowDeleteDialog(false);
}}
>
{labels.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
interface SortableTodoItemProps {
todo: Todo;
project?: Project;
onToggle: (id: string) => void;
onToggleSubtask: (todoId: string, subtaskId: string) => void;
onToggleExpand: (id: string) => void;
onDelete?: (id: string) => void;
onUpdate?: (todo: Todo) => void;
onToggleStar?: (id: string) => void;
isExpanded: boolean;
labels: Todo10Labels;
}
const SortableTodoItem = ({
todo,
project,
onToggle,
onToggleSubtask,
onToggleExpand,
onDelete,
onUpdate,
onToggleStar,
isExpanded,
labels,
}: SortableTodoItemProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
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;
return (
<div ref={setNodeRef} style={style} className="w-full">
<Item
variant={todo.completed ? "muted" : "default"}
className={cn(
"group cursor-pointer items-start rounded-none border-0 border-b border-border",
todo.completed ? "py-1 opacity-60 hover:opacity-80" : "py-2",
)}
onClick={() => onToggle(todo.id)}
>
<ItemMedia>
<div className="flex items-center gap-0.5">
<div className="relative flex items-center">
{todo.subtasks.length > 0 && !todo.completed && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onToggleExpand(todo.id);
}}
className="absolute -start-8"
aria-label={
isExpanded ? labels.collapseSubtasks : labels.expandSubtasks
}
aria-expanded={isExpanded}
>
<ChevronRight
className={cn(
"size-4 text-muted-foreground transition-transform duration-200 rtl:rotate-180",
isExpanded && "rotate-90 rtl:rotate-90",
)}
/>
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
{...attributes}
{...listeners}
className={cn(
"absolute -translate-x-1 scale-50 cursor-grab opacity-0 transition-all group-hover:translate-x-0 group-hover:scale-100 group-hover:opacity-100 active:cursor-grabbing",
todo.subtasks.length > 0 ? "-start-14" : "-start-8",
)}
onClick={(e) => e.stopPropagation()}
aria-label={labels.dragTask(todo.title)}
>
<GripVertical className="size-4 text-muted-foreground" />
</Button>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onToggle(todo.id);
}}
className={cn(
"shrink-0 rounded-full transition-all duration-200",
"-ms-1",
todo.completed && "-me-2",
)}
aria-label={
todo.completed
? labels.markIncomplete(todo.title)
: labels.markComplete(todo.title)
}
>
<div className="relative">
{!todo.completed ? (
<Circle
className={cn(
"size-5",
todo.priority && priorityColors[todo.priority],
)}
/>
) : (
<CheckCircle2
className="size-5 text-muted-foreground"
strokeWidth={2}
/>
)}
</div>
</Button>
</div>
</ItemMedia>
<ItemContent className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<ItemTitle
className={cn(
"flex-1",
todo.completed && "text-muted-foreground line-through",
)}
>
{todo.title}
</ItemTitle>
<div className="flex shrink-0 items-center gap-1">
{todo.dueDate && !todo.completed && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="size-3" />
{formatDueDate(todo.dueDate)}
</div>
)}
{!todo.completed && (
<div
className={cn(
todo.completed
? "opacity-0 transition-opacity group-hover:opacity-100"
: "",
)}
>
<TodoActionsDropdown
todo={todo}
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
onDelete={onDelete}
onUpdate={onUpdate}
onToggleStar={onToggleStar}
labels={labels}
/>
</div>
)}
</div>
</div>
{project && !todo.completed && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground/60">
<span>{project.name}</span>
{totalSubtasks > 0 && (
<>
<span>·</span>
<span>{totalSubtasks}</span>
</>
)}
</div>
)}
{isExpanded && todo.subtasks.length > 0 && !todo.completed && (
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="mt-1.5 space-y-0.5">
{todo.subtasks.map((subtask) => (
<div
key={subtask.id}
className="group/subtask -ms-1 flex items-center gap-1.5 rounded px-1 py-0.5 transition-colors hover:bg-accent/20"
onClick={(e) => {
e.stopPropagation();
onToggleSubtask(todo.id, subtask.id);
}}
>
<button
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded border transition-all",
subtask.completed
? "border-primary bg-primary"
: "border-muted-foreground/30 hover:border-primary/50",
)}
onClick={(e) => {
e.stopPropagation();
onToggleSubtask(todo.id, subtask.id);
}}
aria-label={
subtask.completed
? labels.markIncomplete(subtask.title)
: labels.markComplete(subtask.title)
}
>
{subtask.completed && (
<Check
className="size-2.5 text-primary-foreground"
strokeWidth={3}
/>
)}
</button>
<span
className={cn(
"line-clamp-1 cursor-pointer text-xs transition-all select-none",
subtask.completed
? "text-muted-foreground/50 line-through"
: "text-muted-foreground",
)}
>
{subtask.title}
</span>
</div>
))}
</div>
</div>
)}
</div>
</ItemContent>
</Item>
</div>
);
};
interface AddTaskFormProps {
projects: Project[];
selectedProjectId: string;
onAddTask: (
title: string,
projectId: string,
dueDate?: Date,
priority?: Priority,
) => void;
onProjectChange: (projectId: string) => void;
labels: Todo10Labels;
}
const AddTaskForm = ({
projects,
selectedProjectId,
onAddTask,
onProjectChange,
labels,
}: AddTaskFormProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [title, setTitle] = useState("");
const [date, setDate] = useState<Date | undefined>();
const [priority, setPriority] = useState<Priority | undefined>();
const [datePickerOpen, setDatePickerOpen] = useState(false);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const handleAddTask = () => {
if (title.trim()) {
onAddTask(title.trim(), selectedProjectId, date, priority);
setTitle("");
setDate(undefined);
setPriority(undefined);
setIsExpanded(false);
}
};
const handleCancel = () => {
setTitle("");
setDate(undefined);
setPriority(undefined);
setIsExpanded(false);
};
if (!isExpanded) {
return (
<Button
variant="ghost"
size="lg"
className="w-full justify-start gap-2"
onClick={() => setIsExpanded(true)}
>
<Plus className="h-4 w-4" aria-hidden />
{labels.addTaskLabel}
</Button>
);
}
return (
<InputGroup>
<InputGroupInput
autoFocus
placeholder={labels.taskPlaceholder}
value={title}
onChange={(e) => setTitle(e.target.value)}
aria-label={labels.taskPlaceholder}
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim()) {
e.preventDefault();
handleAddTask();
}
if (e.key === "Escape") {
handleCancel();
}
}}
className="py-3 text-base font-medium"
/>
<InputGroupAddon align="block-end" className="flex-wrap">
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverTrigger asChild>
<InputGroupButton
size="sm"
className={date ? "text-blue-500" : ""}
aria-label={labels.dateLabel}
>
<Calendar className="h-4 w-4" aria-hidden />
{date ? (
<span className="text-xs">{formatDueDate(date)}</span>
) : (
labels.dateLabel
)}
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
className="w-auto p-2"
align="start"
>
<div className="space-y-2">
<div className="space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
setDate(new Date());
setDatePickerOpen(false);
}}
>
{labels.today}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
setDate(tomorrow);
setDatePickerOpen(false);
}}
>
{labels.tomorrow}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
setDate(nextWeek);
setDatePickerOpen(false);
}}
>
{labels.nextWeek}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
setDate(undefined);
setDatePickerOpen(false);
}}
>
{labels.noDate}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<InputGroupButton
size="sm"
className={priority ? priorityColors[priority] : ""}
aria-label={labels.priorityLabel}
>
{priority ? (
<Circle
className={cn("h-4 w-4", priorityColors[priority])}
aria-hidden
/>
) : (
<Flag className="h-4 w-4" aria-hidden />
)}
{priority ? priorityLabels[priority] : labels.priorityLabel}
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
className="w-48 p-2"
align="start"
>
<div className="space-y-1">
{(["P1", "P2", "P3", "P4"] as Priority[]).map((p) => (
<Button
key={p}
variant={priority === p ? "secondary" : "ghost"}
className="w-full justify-start gap-2"
onClick={() => setPriority(p)}
>
<Circle className={cn("h-3.5 w-3.5", priorityColors[p])} />
{priorityLabels[p]}
</Button>
))}
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => setPriority(undefined)}
>
{labels.noPriority}
</Button>
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
<InputGroupAddon align="block-end">
<Popover>
<PopoverTrigger asChild>
<InputGroupButton size="sm" className="gap-2">
{selectedProject ? selectedProject.name : labels.selectProject}
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
className="w-56 p-2"
align="start"
>
<div className="space-y-1">
{projects.map((project) => (
<Button
key={project.id}
variant={
selectedProjectId === project.id ? "secondary" : "ghost"
}
className="w-full justify-start"
onClick={() => onProjectChange(project.id)}
>
{project.name}
</Button>
))}
</div>
</PopoverContent>
</Popover>
<InputGroupButton
size="sm"
onClick={handleCancel}
variant="secondary"
className="ms-auto"
>
<X className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline">{labels.cancel}</span>
</InputGroupButton>
<InputGroupButton
size="sm"
onClick={handleAddTask}
disabled={!title.trim()}
variant="default"
>
<SendHorizonal className="h-4 w-4 rtl:rotate-180" aria-hidden />
<span className="hidden sm:inline">{labels.submitTask}</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
};
const Todo10 = ({
projects = defaultProjects,
initialTodos = defaultTodos,
labels = defaultLabels,
className,
}: Todo10Props) => {
const [todos, setTodos] = useState(initialTodos);
const [sortBy, setSortBy] = useState<SortOption>("manual");
const [filters, setFilters] = useState<TodoFilters>({
projects: [],
priorities: [],
starredOnly: false,
});
const [selectedProjectId, setSelectedProjectId] = useState<string>(
projects[0]?.id ?? "personal",
);
const [isCompletedExpanded, setIsCompletedExpanded] = useState(true);
const [activeTodo, setActiveTodo] = useState<Todo | null>(null);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(
new Set(
initialTodos
.filter((t) => t.subtasks.length > 0 && !t.completed)
.map((t) => t.id),
),
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const filteredTodos = useMemo(() => {
return filterTodos(todos, filters);
}, [todos, filters]);
const { filteredActiveTodos, filteredCompletedTodos } = useMemo(() => {
const completedTasks = filteredTodos.filter((todo) => todo.completed);
const activeTasks = filteredTodos.filter((todo) => !todo.completed);
const sortedActive = sortTodos(activeTasks, sortBy);
const sortedCompleted = sortTodos(completedTasks, sortBy);
return {
filteredActiveTodos: sortedActive,
filteredCompletedTodos: sortedCompleted,
};
}, [filteredTodos, sortBy]);
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) => {
setExpandedTodos((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const addTask = (
title: string,
projectId: string,
dueDate?: Date,
priority?: Priority,
) => {
const newTodo: Todo = {
id: `todo-${Date.now()}`,
title,
completed: false,
projectId,
dueDate,
priority,
subtasks: [],
createdAt: new Date(),
updatedAt: new Date(),
};
setTodos((prevTodos) => [...prevTodos, newTodo]);
};
const handleUpdate = (updatedTodo: Todo) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === updatedTodo.id
? { ...updatedTodo, updatedAt: new Date() }
: todo,
),
);
};
const handleDelete = (id: string) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
};
const handleToggleStar = (id: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, starred: !todo.starred } : 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 && sortBy === "manual") {
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 hasTodos = todos.length > 0;
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">
{!hasTodos ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<List aria-hidden />
</EmptyMedia>
<EmptyTitle>{labels.emptyTitle}</EmptyTitle>
<EmptyDescription>{labels.emptyDescription}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<AddTaskForm
projects={projects}
selectedProjectId={selectedProjectId}
onAddTask={addTask}
onProjectChange={setSelectedProjectId}
labels={labels}
/>
</EmptyContent>
</Empty>
) : (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{labels.allTasks(filteredActiveTodos.length)}
</h3>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredActiveTodos.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<ItemGroup className="w-full">
{filteredActiveTodos.map((todo) => {
const project = projects.find(
(p) => p.id === todo.projectId,
);
return (
<SortableTodoItem
key={todo.id}
todo={todo}
project={project}
onToggle={toggleTodo}
onToggleSubtask={toggleSubtask}
onToggleExpand={toggleExpand}
onDelete={handleDelete}
onUpdate={handleUpdate}
onToggleStar={handleToggleStar}
isExpanded={expandedTodos.has(todo.id)}
labels={labels}
/>
);
})}
</ItemGroup>
</SortableContext>
<AddTaskForm
projects={projects}
selectedProjectId={selectedProjectId}
onAddTask={addTask}
onProjectChange={setSelectedProjectId}
labels={labels}
/>
{filteredCompletedTodos.length > 0 && (
<div className="space-y-3 border-t border-border/50 pt-6">
<button
type="button"
onClick={() =>
setIsCompletedExpanded(!isCompletedExpanded)
}
className="group flex w-full items-center gap-2 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground"
aria-expanded={isCompletedExpanded}
aria-label={
isCompletedExpanded
? `بستن بخش ${labels.completedLabel}`
: `باز کردن بخش ${labels.completedLabel}`
}
>
<ChevronDown
className={cn(
"size-3.5 text-muted-foreground transition-transform duration-200",
!isCompletedExpanded && "-rotate-90",
)}
aria-hidden
/>
<span>{labels.completedLabel}</span>
<span className="rounded-full bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{filteredCompletedTodos.length}
</span>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
isCompletedExpanded
? "max-h-[2000px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<ItemGroup className="w-full">
{filteredCompletedTodos.map((todo) => {
const project = projects.find(
(p) => p.id === todo.projectId,
);
return (
<SortableTodoItem
key={todo.id}
todo={todo}
project={project}
onToggle={toggleTodo}
onToggleSubtask={toggleSubtask}
onToggleExpand={toggleExpand}
onDelete={handleDelete}
onUpdate={handleUpdate}
onToggleStar={handleToggleStar}
isExpanded={expandedTodos.has(todo.id)}
labels={labels}
/>
);
})}
</ItemGroup>
</div>
</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>
</div>
</section>
);
};
export { Todo10 };