Files
reui-blocks/blocks/todo7.tsx
T

928 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { format } from "date-fns";
import { faIR } from "date-fns/locale";
import {
Bell,
Calendar,
ChevronDown,
Filter,
Flag,
GripVertical,
Plus,
SendHorizonal,
SortAsc,
Star,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type Priority = "P1" | "P2" | "P3" | "P4";
type SortOption = "manual" | "title" | "priority";
interface Project {
id: string;
name: string;
color: string;
}
interface PriorityOption {
value: Priority;
label: string;
color: string;
}
interface Todo {
id: string;
title: string;
completed: boolean;
priority?: Priority;
starred: boolean;
projectId: string;
dueDate?: Date;
}
interface Filters {
starredOnly: boolean;
priorities: Priority[];
projects: string[];
}
interface Todo7Props {
heading?: string;
description?: string;
projects?: Project[];
priorities?: PriorityOption[];
filterLabel?: string;
filterByLabel?: string;
starredOnlyLabel?: string;
prioritySectionLabel?: string;
projectsSectionLabel?: string;
clearFiltersLabel?: string;
sortLabel?: string;
sortByLabel?: string;
manualOrderLabel?: string;
titleSortLabel?: string;
prioritySortLabel?: string;
addTaskLabel?: string;
taskPlaceholder?: string;
dateLabel?: string;
priorityLabel?: string;
remindersLabel?: string;
selectProjectLabel?: string;
cancelLabel?: string;
submitLabel?: string;
emptyFiltersMessage?: string;
taskCountLabel?: (count: number) => string;
sortBadges?: Record<SortOption, string>;
starredAriaLabel?: string;
initialTodos?: Todo[];
className?: string;
}
const defaultProjects: Project[] = [
{ id: "personal", name: "کارهای شخصی", color: "#3b82f6" },
{ id: "work", name: "پروژه‌های کاری", color: "#8b5cf6" },
{ id: "shopping", name: "خرید", color: "#10b981" },
{ id: "fitness", name: "سلامت و تناسب اندام", color: "#f59e0b" },
{ id: "learning", name: "یادگیری", color: "#ec4899" },
];
const defaultPriorities: PriorityOption[] = [
{ value: "P1", label: "P1 - فوری", color: "text-red-500" },
{ value: "P2", label: "P2 - بالا", color: "text-orange-500" },
{ value: "P3", label: "P3 - متوسط", color: "text-yellow-500" },
{ value: "P4", label: "P4 - کم", color: "text-blue-500" },
];
const defaultSortBadges: Record<SortOption, string> = {
manual: "د",
title: "ع",
priority: "ا",
};
const defaultTodos: Todo[] = [
{
id: "1",
title: "تکمیل مستندات فنی پروژه",
completed: false,
priority: "P2",
starred: true,
projectId: "work",
dueDate: new Date(2025, 9, 25),
},
{
id: "2",
title: "بررسی و تأیید طراحی رابط کاربری",
completed: false,
priority: "P3",
starred: false,
projectId: "work",
},
{
id: "3",
title: "آماده‌سازی گزارش هفتگی",
completed: false,
priority: "P4",
starred: false,
projectId: "shopping",
},
];
const formatDueDate = (date: Date) =>
format(date, "d MMM", { locale: faIR });
interface SortableTodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
projects: Project[];
priorities: PriorityOption[];
starredAriaLabel: string;
}
const SortableTodoItem = ({
todo,
onToggle,
projects,
priorities,
starredAriaLabel,
}: 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 priority = priorities.find((p) => p.value === todo.priority);
const project = projects.find((p) => p.id === todo.projectId);
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>
<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 flex-wrap items-center gap-2">
<ItemTitle
className={
todo.completed ? "text-muted-foreground line-through" : ""
}
>
{todo.title}
</ItemTitle>
{todo.starred && (
<Star
className="size-4 fill-amber-500 text-amber-500"
aria-label={starredAriaLabel}
/>
)}
{priority && (
<Badge variant="outline" className={cn("text-xs", priority.color)}>
{priority.value}
</Badge>
)}
{project && (
<Badge variant="outline" className="text-xs">
{project.name}
</Badge>
)}
{todo.dueDate && (
<span className="text-xs text-muted-foreground">
{formatDueDate(todo.dueDate)}
</span>
)}
</div>
</ItemContent>
</Item>
</div>
);
};
const Todo7 = ({
heading = "مدیریت پیشرفته وظایف",
description =
"وظیفه با تاریخ، اولویت، یادآور و پروژه بسازید. ترتیب را تغییر دهید و فیلتر و مرتب‌سازی کنید.",
projects = defaultProjects,
priorities = defaultPriorities,
filterLabel = "فیلتر",
filterByLabel = "فیلتر بر اساس",
starredOnlyLabel = "فقط علاقه‌مندی‌ها",
prioritySectionLabel = "اولویت",
projectsSectionLabel = "پروژه‌ها",
clearFiltersLabel = "پاک کردن فیلترها",
sortLabel = "مرتب‌سازی",
sortByLabel = "مرتب‌سازی بر اساس",
manualOrderLabel = "ترتیب دستی",
titleSortLabel = "عنوان (الف–ی)",
prioritySortLabel = "اولویت",
addTaskLabel = "افزودن وظیفه جدید",
taskPlaceholder = "عنوان وظیفه",
dateLabel = "تاریخ",
priorityLabel = "اولویت",
remindersLabel = "یادآورها",
selectProjectLabel = "انتخاب پروژه",
cancelLabel = "انصراف",
submitLabel = "افزودن",
emptyFiltersMessage = "هیچ وظیفه‌ای با فیلترهای شما مطابقت ندارد.",
taskCountLabel = (count) => `${count} وظیفه`,
sortBadges = defaultSortBadges,
starredAriaLabel = "علاقه‌مندی",
initialTodos = defaultTodos,
className,
}: Todo7Props) => {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const [isFormExpanded, setIsFormExpanded] = useState(false);
const [formData, setFormData] = useState({
title: "",
dueDate: undefined as Date | undefined,
priority: null as Priority | null,
reminders: [] as Date[],
projectId: projects[0]?.id ?? "personal",
});
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>("manual");
const [filters, setFilters] = useState<Filters>({
starredOnly: false,
priorities: [],
projects: [],
});
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 resetForm = () => ({
title: "",
dueDate: undefined,
priority: null as Priority | null,
reminders: [] as Date[],
projectId: projects[0]?.id ?? "personal",
});
const handleAddTask = () => {
if (formData.title.trim()) {
const newTodo: Todo = {
id: Date.now().toString(),
title: formData.title.trim(),
completed: false,
priority: formData.priority ?? undefined,
starred: false,
projectId: formData.projectId,
dueDate: formData.dueDate,
};
setTodos((prevTodos) => [...prevTodos, newTodo]);
setFormData(resetForm());
setIsFormExpanded(false);
}
};
const handleCancel = () => {
setFormData(resetForm());
setIsFormExpanded(false);
};
const togglePriorityFilter = (priority: Priority) => {
setFilters((prev) => ({
...prev,
priorities: prev.priorities.includes(priority)
? prev.priorities.filter((p) => p !== priority)
: [...prev.priorities, priority],
}));
};
const toggleProjectFilter = (projectId: string) => {
setFilters((prev) => ({
...prev,
projects: prev.projects.includes(projectId)
? prev.projects.filter((p) => p !== projectId)
: [...prev.projects, projectId],
}));
};
const clearFilters = () => {
setFilters({
starredOnly: false,
priorities: [],
projects: [],
});
};
const activeFilterCount =
(filters.starredOnly ? 1 : 0) +
filters.priorities.length +
filters.projects.length;
const filteredAndSortedTodos = useMemo(() => {
let filtered = todos.filter((todo) => {
if (filters.starredOnly && !todo.starred) return false;
if (
filters.priorities.length > 0 &&
todo.priority &&
!filters.priorities.includes(todo.priority)
)
return false;
if (
filters.projects.length > 0 &&
!filters.projects.includes(todo.projectId)
)
return false;
return true;
});
if (sortBy === "title") {
filtered = [...filtered].sort((a, b) =>
a.title.localeCompare(b.title, "fa"),
);
} else if (sortBy === "priority") {
const priorityOrder: Record<Priority, number> = {
P1: 1,
P2: 2,
P3: 3,
P4: 4,
};
filtered = [...filtered].sort(
(a, b) =>
(priorityOrder[(a.priority || "P4") as Priority] || 4) -
(priorityOrder[(b.priority || "P4") as Priority] || 4),
);
}
return filtered;
}, [todos, filters, sortBy]);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const todo = filteredAndSortedTodos.find((t) => t.id === active.id);
setActiveTodo(todo || null);
if (sortBy !== "manual") {
setSortBy("manual");
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = filteredAndSortedTodos.findIndex(
(item) => item.id === active.id,
);
const newIndex = filteredAndSortedTodos.findIndex(
(item) => item.id === over.id,
);
if (oldIndex !== -1 && newIndex !== -1) {
const reorderedFiltered = arrayMove(
filteredAndSortedTodos,
oldIndex,
newIndex,
);
const reorderedIds = reorderedFiltered.map((t) => t.id);
setTodos((items) => {
const sortedItems = [...items].sort((a, b) => {
const aIndex = reorderedIds.indexOf(a.id);
const bIndex = reorderedIds.indexOf(b.id);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
return sortedItems;
});
}
}
setActiveTodo(null);
};
const selectedProject = projects.find((p) => p.id === formData.projectId);
const selectedPriority = priorities.find(
(p) => p.value === formData.priority,
);
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">
<div className="flex flex-wrap items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" aria-hidden />
{filterLabel}
{activeFilterCount > 0 && (
<Badge variant="secondary" className="px-1.5 py-0">
{activeFilterCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel>{filterByLabel}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={filters.starredOnly}
onCheckedChange={(checked) =>
setFilters((prev) => ({
...prev,
starredOnly: checked,
}))
}
>
<Star className="h-4 w-4" aria-hidden />
{starredOnlyLabel}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">
{prioritySectionLabel}
</DropdownMenuLabel>
{priorities.map((priority) => (
<DropdownMenuCheckboxItem
key={priority.value}
checked={filters.priorities.includes(priority.value)}
onCheckedChange={() =>
togglePriorityFilter(priority.value)
}
>
<Flag
className={cn("h-4 w-4", priority.color)}
aria-hidden
/>
{priority.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">
{projectsSectionLabel}
</DropdownMenuLabel>
{projects.map((project) => (
<DropdownMenuCheckboxItem
key={project.id}
checked={filters.projects.includes(project.id)}
onCheckedChange={() => toggleProjectFilter(project.id)}
>
{project.name}
</DropdownMenuCheckboxItem>
))}
{activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={clearFilters}
>
<X className="h-4 w-4" aria-hidden />
{clearFiltersLabel}
</Button>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-8" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<SortAsc className="h-4 w-4" aria-hidden />
{sortLabel}
<Badge variant="secondary" className="px-1.5 py-0">
{sortBadges[sortBy]}
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>{sortByLabel}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => setSortBy(value as SortOption)}
>
<DropdownMenuRadioItem value="manual">
{manualOrderLabel}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title">
{titleSortLabel}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="priority">
{prioritySortLabel}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="ms-auto text-sm text-muted-foreground">
{taskCountLabel(filteredAndSortedTodos.length)}
</div>
</div>
{!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>
) : (
<InputGroup className="flex-col">
<InputGroupInput
autoFocus
placeholder={taskPlaceholder}
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({
...prev,
title: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter" && formData.title.trim()) {
handleAddTask();
}
if (e.key === "Escape") {
handleCancel();
}
}}
className="py-3 text-base font-medium"
aria-label={taskPlaceholder}
/>
<InputGroupAddon
align="block-end"
className="flex-wrap border-t"
>
<Popover
open={datePickerOpen}
onOpenChange={setDatePickerOpen}
>
<PopoverTrigger asChild>
<InputGroupButton
size="sm"
className={formData.dueDate ? "text-chart-1" : ""}
aria-label={dateLabel}
>
<Calendar className="h-4 w-4" aria-hidden />
{formData.dueDate ? (
<span className="text-xs">
{formatDueDate(formData.dueDate)}
</span>
) : (
dateLabel
)}
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
align="start"
className="w-auto p-0"
>
<CalendarComponent
mode="single"
selected={formData.dueDate}
onSelect={(date) => {
setFormData((prev) => ({ ...prev, dueDate: date }));
if (date) {
setDatePickerOpen(false);
}
}}
disabled={(date) => date < new Date()}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<InputGroupButton
size="sm"
className={
selectedPriority ? selectedPriority.color : ""
}
aria-label={priorityLabel}
>
{selectedPriority ? (
<Flag
className={cn(
"h-4 w-4",
selectedPriority.color,
)}
aria-hidden
/>
) : (
<Flag className="h-4 w-4" aria-hidden />
)}
{selectedPriority?.label || priorityLabel}
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
className="w-48 p-2"
align="start"
>
<div className="space-y-1">
{priorities.map((priority) => (
<Button
key={priority.value}
variant={
formData.priority === priority.value
? "secondary"
: "ghost"
}
className="w-full justify-start gap-2"
onClick={() =>
setFormData((prev) => ({
...prev,
priority: priority.value,
}))
}
>
<Flag
className={cn("h-3.5 w-3.5", priority.color)}
aria-hidden
/>
{priority.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
<InputGroupButton
size="sm"
className={
formData.reminders.length > 0 ? "text-chart-2" : ""
}
aria-label={remindersLabel}
>
<Bell className="h-4 w-4" aria-hidden />
{formData.reminders.length > 0
? `${formData.reminders.length}`
: remindersLabel}
</InputGroupButton>
</InputGroupAddon>
<InputGroupAddon align="block-end" className="border-t">
<Popover>
<PopoverTrigger asChild>
<InputGroupButton size="sm" className="gap-2">
{selectedProject
? selectedProject.name
: selectProjectLabel}
<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={
formData.projectId === project.id
? "secondary"
: "ghost"
}
className="w-full justify-start"
onClick={() =>
setFormData((prev) => ({
...prev,
projectId: 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">{cancelLabel}</span>
</InputGroupButton>
<InputGroupButton
size="sm"
onClick={handleAddTask}
disabled={!formData.title.trim()}
variant="default"
>
<SendHorizonal
className="h-4 w-4 rtl:rotate-180"
aria-hidden
/>
<span className="hidden sm:inline">{submitLabel}</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)}
<SortableContext
items={filteredAndSortedTodos.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<ItemGroup>
{filteredAndSortedTodos.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{emptyFiltersMessage}
</div>
) : (
filteredAndSortedTodos.map((todo) => (
<SortableTodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
projects={projects}
priorities={priorities}
starredAriaLabel={starredAriaLabel}
/>
))
)}
</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>
<div className="flex items-center gap-2">
<ItemTitle
className={
activeTodo.completed
? "text-muted-foreground line-through"
: ""
}
>
{activeTodo.title}
</ItemTitle>
{activeTodo.starred && (
<Star
className="size-4 fill-amber-500 text-amber-500"
aria-hidden
/>
)}
{activeTodo.priority && (
<Badge variant="outline" className="text-xs">
{activeTodo.priority}
</Badge>
)}
</div>
</ItemContent>
</Item>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
</section>
);
};
export { Todo7 };