Files
reui-blocks/blocks/todo6.tsx
T

637 lines
20 KiB
TypeScript
Raw Normal View History

"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 { Filter, GripVertical, Plus, SortAsc, Star, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type Priority = "high" | "medium" | "low";
type SortOption = "manual" | "title" | "priority";
interface Todo {
id: string;
title: string;
completed: boolean;
priority: Priority;
starred: boolean;
}
interface Filters {
starredOnly: boolean;
priorities: Priority[];
}
interface Todo6Props {
heading?: string;
description?: string;
filterLabel?: string;
filterByLabel?: string;
starredOnlyLabel?: string;
prioritySectionLabel?: string;
clearFiltersLabel?: string;
sortLabel?: string;
sortByLabel?: string;
manualOrderLabel?: string;
titleSortLabel?: string;
prioritySortLabel?: string;
addTaskLabel?: string;
taskPlaceholder?: string;
cancelLabel?: string;
submitLabel?: string;
emptyFiltersMessage?: string;
taskCountLabel?: (count: number) => string;
priorityLabels?: Record<Priority, string>;
sortBadges?: Record<SortOption, string>;
starredAriaLabel?: string;
initialTodos?: Todo[];
className?: string;
}
const defaultPriorityLabels: Record<Priority, string> = {
high: "زیاد",
medium: "متوسط",
low: "کم",
};
const defaultSortBadges: Record<SortOption, string> = {
manual: "د",
title: "ع",
priority: "ا",
};
const defaultTodos: Todo[] = [
{
id: "1",
title: "تکمیل مستندات فنی پروژه",
completed: false,
priority: "high",
starred: true,
},
{
id: "2",
title: "بررسی و تأیید طراحی رابط کاربری",
completed: false,
priority: "medium",
starred: false,
},
{
id: "3",
title: "آماده‌سازی گزارش هفتگی",
completed: false,
priority: "low",
starred: false,
},
{
id: "4",
title: "بازبینی کد منابع",
completed: true,
priority: "medium",
starred: false,
},
{
id: "5",
title: "هماهنگی با تیم طراحی",
completed: false,
priority: "high",
starred: true,
},
];
interface SortableTodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
priorityLabels: Record<Priority, string>;
starredAriaLabel: string;
}
const SortableTodoItem = ({
todo,
onToggle,
priorityLabels,
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,
};
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 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}
/>
)}
<Badge variant="outline" className="text-xs">
{priorityLabels[todo.priority]}
</Badge>
</div>
</ItemContent>
</Item>
</div>
);
};
const Todo6 = ({
heading = "مدیریت وظایف با فیلتر و مرتب‌سازی",
description =
"وظیفه جدید بسازید، بر اساس اولویت و علاقه‌مندی فیلتر کنید و بر اساس عنوان یا اولویت مرتب کنید.",
filterLabel = "فیلتر",
filterByLabel = "فیلتر بر اساس",
starredOnlyLabel = "فقط علاقه‌مندی‌ها",
prioritySectionLabel = "اولویت",
clearFiltersLabel = "پاک کردن فیلترها",
sortLabel = "مرتب‌سازی",
sortByLabel = "مرتب‌سازی بر اساس",
manualOrderLabel = "ترتیب دستی",
titleSortLabel = "عنوان (الف–ی)",
prioritySortLabel = "اولویت",
addTaskLabel = "افزودن وظیفه جدید",
taskPlaceholder = "عنوان وظیفه",
cancelLabel = "انصراف",
submitLabel = "افزودن",
emptyFiltersMessage = "هیچ وظیفه‌ای با فیلترهای شما مطابقت ندارد.",
taskCountLabel = (count) => `${count} وظیفه`,
priorityLabels = defaultPriorityLabels,
sortBadges = defaultSortBadges,
starredAriaLabel = "علاقه‌مندی",
initialTodos = defaultTodos,
className,
}: Todo6Props) => {
const [todos, setTodos] = useState(initialTodos);
const [isFormExpanded, setIsFormExpanded] = useState(false);
const [newTaskTitle, setNewTaskTitle] = useState("");
const [sortBy, setSortBy] = useState<SortOption>("manual");
const [filters, setFilters] = useState<Filters>({
starredOnly: false,
priorities: [],
});
const [activeTodo, setActiveTodo] = useState<Todo | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const toggleTodo = (id: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
};
const handleAddTask = () => {
if (newTaskTitle.trim()) {
const newTodo: Todo = {
id: Date.now().toString(),
title: newTaskTitle.trim(),
completed: false,
priority: "medium",
starred: false,
};
setTodos((prevTodos) => [...prevTodos, newTodo]);
setNewTaskTitle("");
setIsFormExpanded(false);
}
};
const togglePriorityFilter = (priority: Priority) => {
setFilters((prev) => ({
...prev,
priorities: prev.priorities.includes(priority)
? prev.priorities.filter((p) => p !== priority)
: [...prev.priorities, priority],
}));
};
const clearFilters = () => {
setFilters({
starredOnly: false,
priorities: [],
});
};
const activeFilterCount =
(filters.starredOnly ? 1 : 0) + filters.priorities.length;
const filteredAndSortedTodos = useMemo(() => {
let filtered = todos.filter((todo) => {
if (filters.starredOnly && !todo.starred) return false;
if (
filters.priorities.length > 0 &&
!filters.priorities.includes(todo.priority)
)
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> = {
high: 1,
medium: 2,
low: 3,
};
filtered = [...filtered].sort(
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority],
);
}
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);
};
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>
<DropdownMenuCheckboxItem
checked={filters.priorities.includes("high")}
onCheckedChange={() => togglePriorityFilter("high")}
>
{priorityLabels.high}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={filters.priorities.includes("medium")}
onCheckedChange={() => togglePriorityFilter("medium")}
>
{priorityLabels.medium}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={filters.priorities.includes("low")}
onCheckedChange={() => togglePriorityFilter("low")}
>
{priorityLabels.low}
</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>
) : (
<div className="flex flex-col gap-2 rounded-lg border p-4">
<Input
autoFocus
placeholder={taskPlaceholder}
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTaskTitle.trim()) {
handleAddTask();
}
if (e.key === "Escape") {
setNewTaskTitle("");
setIsFormExpanded(false);
}
}}
className="text-base font-medium"
aria-label={taskPlaceholder}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
setNewTaskTitle("");
setIsFormExpanded(false);
}}
>
<X className="h-4 w-4" aria-hidden />
{cancelLabel}
</Button>
<Button
size="sm"
onClick={handleAddTask}
disabled={!newTaskTitle.trim()}
>
<Plus className="h-4 w-4" aria-hidden />
{submitLabel}
</Button>
</div>
</div>
)}
<SortableContext
items={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}
priorityLabels={priorityLabels}
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
/>
)}
<Badge variant="outline" className="text-xs">
{priorityLabels[activeTodo.priority]}
</Badge>
</div>
</ItemContent>
</Item>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
</section>
);
};
export { Todo6 };