Files
reui-blocks/blocks/todo9.tsx
T

509 lines
16 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 { GripVertical, Plus, Search, Tag, 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 { Input } from "@/components/ui/input";
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface TagOption {
id: string;
label: string;
}
interface Todo {
id: string;
title: string;
completed: boolean;
tags: string[];
}
interface Todo9Props {
heading?: string;
description?: string;
searchPlaceholder?: string;
filterByTagsLabel?: string;
clearFiltersLabel?: string;
emptySearchMessage?: string;
emptyListMessage?: string;
addTagLabel?: string;
tagCatalog?: TagOption[];
initialTodos?: Todo[];
className?: string;
}
const defaultTagCatalog: TagOption[] = [
{ id: "work", label: "کاری" },
{ id: "personal", label: "شخصی" },
{ id: "urgent", label: "فوری" },
{ id: "shopping", label: "خرید" },
{ id: "health", label: "سلامت" },
{ id: "fitness", label: "ورزش" },
{ id: "learning", label: "یادگیری" },
{ id: "documentation", label: "مستندات" },
{ id: "code-review", label: "بازبینی کد" },
];
const defaultTodos: Todo[] = [
{
id: "1",
title: "تکمیل مستندات فنی پروژه",
completed: false,
tags: ["work", "urgent", "documentation"],
},
{
id: "2",
title: "بررسی و تأیید طراحی رابط کاربری",
completed: false,
tags: ["work", "code-review"],
},
{
id: "3",
title: "آماده‌سازی گزارش هفتگی",
completed: false,
tags: ["personal", "shopping"],
},
{
id: "4",
title: "هماهنگی جلسات تیم",
completed: true,
tags: ["health", "fitness"],
},
{
id: "5",
title: "بررسی مستندات فنی",
completed: false,
tags: ["learning", "work"],
},
];
interface SortableTodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
onRemoveTag: (id: string, tag: string) => void;
onAddTag: (id: string, tag: string) => void;
tagCatalog: TagOption[];
getTagLabel: (tagId: string) => string;
addTagLabel: string;
}
const SortableTodoItem = ({
todo,
onToggle,
onRemoveTag,
onAddTag,
tagCatalog,
getTagLabel,
addTagLabel,
}: 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 availableToAdd = tagCatalog.filter((tag) => !todo.tags.includes(tag.id));
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-col gap-2">
<div className="flex items-center gap-2">
<ItemTitle
className={
todo.completed ? "text-muted-foreground line-through" : ""
}
>
{todo.title}
</ItemTitle>
</div>
<div className="flex flex-wrap items-center gap-1">
{todo.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="cursor-pointer text-xs"
onClick={(e) => {
e.stopPropagation();
onRemoveTag(todo.id, tag);
}}
aria-label={`حذف برچسب ${getTagLabel(tag)}`}
>
{getTagLabel(tag)}
<X className="ms-1 size-3" aria-hidden />
</Badge>
))}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={(e) => e.stopPropagation()}
aria-label={addTagLabel}
>
<Plus className="size-3" aria-hidden />
<Tag className="size-3" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent
dir="rtl"
lang="fa"
className="w-48 p-2"
align="start"
>
<div className="space-y-1">
{availableToAdd.map((tag) => (
<Button
key={tag.id}
variant="ghost"
className="w-full justify-start"
onClick={() => onAddTag(todo.id, tag.id)}
>
<Tag className="me-2 size-3" aria-hidden />
{tag.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</ItemContent>
</Item>
</div>
);
};
const Todo9 = ({
heading = "مدیریت وظایف با جستجو و برچسب",
description =
"جستجوی لحظه‌ای، چند برچسب برای هر وظیفه، فیلتر بر اساس برچسب و مرتب‌سازی با کشیدن. برچسب‌ها را به هر وظیفه اضافه یا حذف کنید.",
searchPlaceholder = "جستجوی وظایف...",
filterByTagsLabel = "فیلتر بر اساس برچسب:",
clearFiltersLabel = "پاک کردن فیلترها",
emptySearchMessage = "هیچ وظیفه‌ای با جستجوی شما مطابقت ندارد.",
emptyListMessage = "وظیفه‌ای وجود ندارد.",
addTagLabel = "افزودن برچسب",
tagCatalog = defaultTagCatalog,
initialTodos = defaultTodos,
className,
}: Todo9Props) => {
const [todos, setTodos] = useState(initialTodos);
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [activeTodo, setActiveTodo] = useState<Todo | null>(null);
const getTagLabel = (tagId: string) =>
tagCatalog.find((tag) => tag.id === tagId)?.label ?? tagId;
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 removeTag = (id: string, tag: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id
? { ...todo, tags: todo.tags.filter((t) => t !== tag) }
: todo,
),
);
};
const addTag = (id: string, tag: string) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id && !todo.tags.includes(tag)
? { ...todo, tags: [...todo.tags, tag] }
: todo,
),
);
};
const toggleTagFilter = (tag: string) => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
);
};
const filteredTodos = useMemo(() => {
const query = searchQuery.trim();
return todos.filter((todo) => {
if (query && !todo.title.includes(query)) {
return false;
}
if (selectedTags.length > 0) {
const hasSelectedTag = selectedTags.some((tag) =>
todo.tags.includes(tag),
);
if (!hasSelectedTag) return false;
}
return true;
});
}, [todos, searchQuery, selectedTags]);
const allTags = useMemo(() => {
const tagSet = new Set<string>();
todos.forEach((todo) => {
todo.tags.forEach((tag) => tagSet.add(tag));
});
return Array.from(tagSet).sort((a, b) =>
getTagLabel(a).localeCompare(getTagLabel(b), "fa"),
);
}, [todos, tagCatalog]);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const todo = filteredTodos.find((t) => t.id === active.id);
setActiveTodo(todo || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = filteredTodos.findIndex((item) => item.id === active.id);
const newIndex = filteredTodos.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reorderedFiltered = arrayMove(filteredTodos, 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-col gap-4">
<div className="relative">
<Search
className="absolute top-1/2 inset-s-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9"
aria-label={searchPlaceholder}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{filterByTagsLabel}
</span>
{allTags.map((tag) => (
<Badge
key={tag}
variant={
selectedTags.includes(tag) ? "default" : "outline"
}
className="cursor-pointer"
onClick={() => toggleTagFilter(tag)}
>
<Tag className="me-1 size-3" aria-hidden />
{getTagLabel(tag)}
</Badge>
))}
{selectedTags.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedTags([])}
>
<X className="me-1 size-3" aria-hidden />
{clearFiltersLabel}
</Button>
)}
</div>
</div>
<SortableContext
items={filteredTodos.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<ItemGroup>
{filteredTodos.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchQuery || selectedTags.length > 0
? emptySearchMessage
: emptyListMessage}
</div>
) : (
filteredTodos.map((todo) => (
<SortableTodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onRemoveTag={removeTag}
onAddTag={addTag}
tagCatalog={tagCatalog}
getTagLabel={getTagLabel}
addTagLabel={addTagLabel}
/>
))
)}
</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>
<ItemTitle
className={
activeTodo.completed
? "text-muted-foreground line-through"
: ""
}
>
{activeTodo.title}
</ItemTitle>
</ItemContent>
</Item>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
</section>
);
};
export { Todo9 };