1528 lines
47 KiB
TypeScript
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 };
|