Files
reui-blocks/blocks/todo6.tsx
T

637 lines
20 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 { 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 };