kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { CheckCircle2, Circle, GripVertical, Plus, Trash2 } from "lucide-react";
2import { useState } from "react";
3import { useTranslation } from "react-i18next";
4import { Button } from "@/components/ui/button";
5import { Input } from "@/components/ui/input";
6import { Switch } from "@/components/ui/switch";
7import { useCreateColumn } from "@/hooks/mutations/column/use-create-column";
8import { useDeleteColumn } from "@/hooks/mutations/column/use-delete-column";
9import { useReorderColumns } from "@/hooks/mutations/column/use-reorder-columns";
10import { useUpdateColumn } from "@/hooks/mutations/column/use-update-column";
11import { useGetColumns } from "@/hooks/queries/column/use-get-columns";
12import { toast } from "@/lib/toast";
13
14type ColumnEditorProps = {
15 projectId: string;
16};
17
18export default function ColumnEditor({ projectId }: ColumnEditorProps) {
19 const { t } = useTranslation();
20 const { data: columns, isLoading } = useGetColumns(projectId);
21 const { mutateAsync: createColumn } = useCreateColumn();
22 const { mutateAsync: updateColumn } = useUpdateColumn();
23 const { mutateAsync: deleteColumn } = useDeleteColumn();
24 const { mutateAsync: reorderColumns } = useReorderColumns();
25 const [newColumnName, setNewColumnName] = useState("");
26 const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
27
28 const handleCreate = async () => {
29 if (!newColumnName.trim()) return;
30 try {
31 await createColumn({
32 projectId,
33 data: { name: newColumnName.trim() },
34 });
35 setNewColumnName("");
36 toast.success(t("settings:columnEditor.toastCreated"));
37 } catch (error) {
38 toast.error(
39 error instanceof Error
40 ? error.message
41 : t("settings:columnEditor.toastCreateError"),
42 );
43 }
44 };
45
46 const handleRename = async (id: string, name: string) => {
47 try {
48 await updateColumn({ id, projectId, data: { name } });
49 toast.success(t("settings:columnEditor.toastRenamed"));
50 } catch (error) {
51 toast.error(
52 error instanceof Error
53 ? error.message
54 : t("settings:columnEditor.toastRenameError"),
55 );
56 }
57 };
58
59 const handleToggleFinal = async (id: string, isFinal: boolean) => {
60 try {
61 await updateColumn({ id, projectId, data: { isFinal } });
62 toast.success(
63 isFinal
64 ? t("settings:columnEditor.toastFinalOn")
65 : t("settings:columnEditor.toastFinalOff"),
66 );
67 } catch (error) {
68 toast.error(
69 error instanceof Error
70 ? error.message
71 : t("settings:columnEditor.toastUpdateError"),
72 );
73 }
74 };
75
76 const handleDelete = async (id: string) => {
77 try {
78 await deleteColumn({ id, projectId });
79 toast.success(t("settings:columnEditor.toastDeleted"));
80 } catch (error) {
81 toast.error(
82 error instanceof Error
83 ? error.message
84 : t("settings:columnEditor.toastDeleteError"),
85 );
86 }
87 };
88
89 const handleDragStart = (index: number) => {
90 setDraggedIndex(index);
91 };
92
93 const handleDragOver = (e: React.DragEvent, index: number) => {
94 e.preventDefault();
95 if (draggedIndex === null || draggedIndex === index || !columns) return;
96
97 const reordered = [...columns];
98 const [removed] = reordered.splice(draggedIndex, 1);
99 reordered.splice(index, 0, removed);
100
101 const updates = reordered.map((col, i) => ({ id: col.id, position: i }));
102 reorderColumns({ projectId, columns: updates });
103 setDraggedIndex(index);
104 };
105
106 const handleDragEnd = () => {
107 setDraggedIndex(null);
108 };
109
110 if (isLoading) {
111 return (
112 <div className="text-sm text-muted-foreground">
113 {t("settings:columnEditor.loading")}
114 </div>
115 );
116 }
117
118 return (
119 <div className="space-y-3">
120 <div className="space-y-1">
121 {columns?.map((col, index) => (
122 // biome-ignore lint/a11y/useSemanticElements: false positive for role="listitem"
123 <div
124 key={col.id}
125 role="listitem"
126 draggable
127 onDragStart={() => handleDragStart(index)}
128 onDragOver={(e) => handleDragOver(e, index)}
129 onDragEnd={handleDragEnd}
130 className="flex items-center gap-2 p-2 border border-border rounded-md bg-sidebar hover:bg-sidebar-accent/50 transition-colors"
131 >
132 <GripVertical className="w-4 h-4 text-muted-foreground cursor-grab shrink-0" />
133 <Input
134 defaultValue={col.name}
135 className="h-8 text-sm flex-1"
136 onBlur={(e) => {
137 if (e.target.value !== col.name) {
138 handleRename(col.id, e.target.value);
139 }
140 }}
141 onKeyDown={(e) => {
142 if (e.key === "Enter") {
143 e.preventDefault();
144 e.currentTarget.blur();
145 }
146 }}
147 />
148 <div className="flex items-center gap-1.5 shrink-0">
149 <div
150 className="flex items-center gap-2"
151 title={t("settings:columnEditor.doneColumnTooltip")}
152 >
153 {col.isFinal ? (
154 <CheckCircle2 className="w-3.5 h-3.5 text-muted-foreground" />
155 ) : (
156 <Circle className="w-3.5 h-3.5 text-muted-foreground" />
157 )}
158 <span className="text-xs text-muted-foreground whitespace-nowrap">
159 {t("settings:columnEditor.doneColumn")}
160 </span>
161 <Switch
162 checked={col.isFinal}
163 onCheckedChange={(checked) =>
164 handleToggleFinal(col.id, checked)
165 }
166 aria-label={t("settings:columnEditor.markDoneAria", {
167 name: col.name,
168 })}
169 className="scale-75"
170 />
171 <span className="text-[11px] text-muted-foreground w-8">
172 {col.isFinal
173 ? t("settings:columnEditor.on")
174 : t("settings:columnEditor.off")}
175 </span>
176 </div>
177 <Button
178 variant="ghost"
179 size="sm"
180 className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
181 onClick={() => handleDelete(col.id)}
182 >
183 <Trash2 className="w-3.5 h-3.5" />
184 </Button>
185 </div>
186 </div>
187 ))}
188 </div>
189
190 <div className="flex items-center gap-2">
191 <Input
192 placeholder={t("settings:columnEditor.newColumnPlaceholder")}
193 value={newColumnName}
194 onChange={(e) => setNewColumnName(e.target.value)}
195 className="h-8 text-sm flex-1"
196 onKeyDown={(e) => {
197 if (e.key === "Enter") handleCreate();
198 }}
199 />
200 <Button
201 variant="outline"
202 size="sm"
203 onClick={handleCreate}
204 disabled={!newColumnName.trim()}
205 className="h-8 gap-1"
206 >
207 <Plus className="w-3.5 h-3.5" />
208 {t("settings:columnEditor.add")}
209 </Button>
210 </div>
211 </div>
212 );
213}