diff --git a/src/components/settings/TagsPage.tsx b/src/components/settings/TagsPage.tsx index 21519fb..5d1d128 100644 --- a/src/components/settings/TagsPage.tsx +++ b/src/components/settings/TagsPage.tsx @@ -1,9 +1,319 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; +import { Archive, ArchiveX, ArrowLeft, Pen, Plus, Save, Trash2, XCircle } from "../icons"; +import { useHotkey } from "../hotkeys/HotkeysProvider"; +import { localDb } from "../lib/local-db"; +import { createTagInDb, fetchTags, type Tag } from "../pages/history/data"; import type { SubPageProps } from "../types"; +const TAG_COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", "#6366f1", "#f97316", "#14b8a6", "#a855f7"]; + +const DELETE_ARCHIVED_CONFIRM = "Are you sure you want to delete this tag? This will remove it from all focus sessions and cannot be undone."; +const DELETE_ACTIVE_CONFIRM = `Are you sure you want to delete this tag? + +This will: +• Remove it from all focus sessions +• Delete the tag permanently + +Consider archiving instead if you want to keep the historical data. This action cannot be undone.`; + +const INPUT_CLASS = + "w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"; + +async function updateTag(id: string, patch: Partial): Promise { + const { data, error } = await localDb + .from("tags") + .update({ ...patch, updated_at: new Date().toISOString() }) + .eq("id", id) + .select() + .single(); + if (error || !data) throw new Error(`Failed to update tag: ${error?.message}`); + return data; +} + +async function deleteTag(id: string): Promise { + const { error: connError } = await localDb.from("connections").delete().eq("target_type", "tag").eq("target_id", id); + if (connError) throw new Error(`Failed to delete tag connections: ${connError.message}`); + const { error } = await localDb.from("tags").delete().eq("id", id); + if (error) throw new Error(`Failed to delete tag: ${error.message}`); +} + +async function assertNameFree(userId: string, name: string, exceptId: string): Promise { + const { data } = await localDb.from("tags").select("*").eq("user_id", userId).eq("name", name); + // localStorage has no unique constraints, so (user_id, name) uniqueness is enforced here. + if (data?.some((t) => t.id !== exceptId)) throw new Error("duplicate key value violates unique constraint"); +} + +function ColorGrid({ value, onChange }: { value: string; onChange(color: string): void }) { + return ( +
+
Color
+
+ {TAG_COLORS.map((color) => ( +
+
+ ); +} + export function TagsPage({ userId, onBack }: SubPageProps) { - void userId; - void onBack; - return null; + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editColor, setEditColor] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [newColor, setNewColor] = useState(TAG_COLORS[0]); + const [showArchived, setShowArchived] = useState(false); + useHotkey("list.new", () => setIsAdding(true)); + + const loadTags = useCallback(async () => { + try { + setTags(await fetchTags(userId, true)); + } catch (error) { + console.error("Error loading tags:", error); + } finally { + setLoading(false); + } + }, [userId]); + + useEffect(() => { + queueMicrotask(() => void loadTags()); + }, [loadTags]); + + function resetNew() { + setIsAdding(false); + setNewName(""); + setNewColor(TAG_COLORS[0]); + } + + async function addTag() { + if (!newName.trim()) return; + try { + const tag = await createTagInDb(userId, newName.trim(), newColor); + setTags([...tags, tag]); + resetNew(); + } catch (error) { + console.error("Error adding tag:", error); + alert("Failed to create tag. It might already exist."); + } + } + + function startEdit(tag: Tag) { + setEditingId(tag.id); + setEditName(tag.name); + setEditColor(tag.color); + } + + async function saveEdit(id: string) { + if (!editName.trim()) return; + try { + await assertNameFree(userId, editName.trim(), id); + const updated = await updateTag(id, { name: editName.trim(), color: editColor }); + setTags(tags.map((t) => (t.id === id ? updated : t))); + setEditingId(null); + } catch (error) { + console.error("Error updating tag:", error); + alert("Failed to update tag. The name might already exist."); + } + } + + function cancelEdit() { + setEditingId(null); + setEditName(""); + setEditColor(""); + } + + async function toggleArchive(tag: Tag) { + try { + await updateTag(tag.id, { archived: !tag.archived }); + await loadTags(); + } catch (error) { + console.error("Error archiving/unarchiving tag:", error); + alert("Failed to archive/unarchive tag."); + } + } + + async function removeTag(tag: Tag) { + if (!confirm(tag.archived ? DELETE_ARCHIVED_CONFIRM : DELETE_ACTIVE_CONFIRM)) return; + try { + await deleteTag(tag.id); + setTags(tags.filter((t) => t.id !== tag.id)); + } catch (error) { + console.error("Error deleting tag:", error); + alert("Failed to delete tag."); + } + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + const activeTags = tags.filter((t) => !t.archived); + const archivedTags = tags.filter((t) => t.archived); + + return ( +
+
+
+ +
+

Tags

+

Organize your focus sessions

+
+
+ +
+ + {isAdding && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addTag(); + } else if (e.key === "Escape") resetNew(); + }} + className={INPUT_CLASS} + placeholder="Tag name" + maxLength={30} + autoFocus + /> + +
+ + +
+
+ )} + +
+ {activeTags.length === 0 && !isAdding ? ( +
+

No tags yet

+

Create tags to organize your focus sessions

+
+ ) : ( + activeTags.map((tag) => ( +
+ {editingId === tag.id ? ( +
+ setEditName(e.target.value)} + className={INPUT_CLASS} + placeholder="Tag name" + maxLength={30} + autoFocus + /> + +
+ + +
+
+ ) : ( +
+
+
+

{tag.name}

+
+
+ + + +
+
+ )} +
+ )) + )} + + {archivedTags.length > 0 && ( +
+ + {showArchived && ( +
+ {archivedTags.map((tag) => ( +
+
+
+
+

{tag.name}

+
+
+ + +
+
+
+ ))} +
+ )} +
+ )} +
+
+ ); }