509 lines
16 KiB
TypeScript
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 };
|