"use client"; import React, { useState } from "react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition, } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section"; import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { KEYWORD_PREFIX } from "@/lib/thread-utils"; import { buildKeywordTree, composeKeywordId, getParentKeywordId, hasChildKeywords, isKeywordDescendant, keywordLevels, type KeywordNode, MAX_KEYWORD_ID_LENGTH, } from "@/lib/keyword-nesting"; import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; import { useShortenedText } from "@/hooks/use-shortened-text"; const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); function KeywordColorPicker({ value, onChange, }: { value: string; onChange: (color: string) => void; }) { return (
{PALETTE_KEYS.map((colorKey) => (
); } function KeywordRow({ keyword, keywords, nestedTags, onEdit, onDelete, onDragStart, onDragOver, onDrop, onDragEnd, isDragOver, isDragging, }: { keyword: KeywordDefinition; keywords: KeywordDefinition[]; nestedTags: boolean; onEdit: () => void; onDelete: () => void; onDragStart: () => void; onDragOver: (e: React.DragEvent) => void; onDrop: () => void; onDragEnd: () => void; isDragOver: boolean; isDragging: boolean; }) { const t = useTranslations("settings.keywords"); const palette = KEYWORD_PALETTE[keyword.color]; const hasChildren = hasChildKeywords(keyword.id, keywords); const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags)); const [nameRef, shortenedName] = useShortenedText(nameCandidates); // Measured with the prefix attached, since that is what occupies the column. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); return (
{shortenedName} {shortenedKeyword}
); } function KeywordEditForm({ initial, keywords, existingIds, nestedTags, onSave, onCancel, }: { initial?: KeywordDefinition; keywords: KeywordDefinition[]; existingIds: string[]; nestedTags: boolean; onSave: (keyword: KeywordDefinition) => void; onCancel: () => void; }) { const t = useTranslations("settings.keywords"); const [label, setLabel] = useState(initial?.label || ""); const [color, setColor] = useState(initial?.color || "blue"); const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : ""); const isEditing = !!initial; // Renaming or re-parenting a tag rewrites the keyword on every message below // it, and this client only knows about the tags in its own settings - the // server may hold nested keywords created elsewhere. Freeze the identity of a // tag that has children and allow the color to change. const isLocked = !!initial && hasChildKeywords(initial.id, keywords); const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label); const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId); const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH; const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong; // Every tag is a candidate parent except the one being edited and anything // already below it, which would detach the branch from its own root. const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }]; const collectParentOptions = (nodes: KeywordNode[]) => { for (const node of nodes) { if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue; parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) }); collectParentOptions(node.children); } }; collectParentOptions(buildKeywordTree(keywords)); const handleSave = () => { if (!isValid) return; if (isLocked && initial) { onSave({ ...initial, color }); return; } onSave({ id: normalizedId, label: label.trim(), color }); }; return (
{nestedTags && (
setLabel(e.target.value)} disabled={isLocked} className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60" placeholder={t("label_placeholder")} autoFocus maxLength={30} onKeyDown={(e) => e.key === "Enter" && handleSave()} /> {nestedTags && normalizedId.length > 0 && (

{KEYWORD_PREFIX + normalizedId}

)} {isLocked && (

{t("has_children_locked")}

)} {isDuplicate && (

{t("id_exists")}

)} {isTooLong && (

{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}

)}
); } export function KeywordSettings() { const t = useTranslations("settings.keywords"); const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } = useSettingsStore(); const { client } = useAuthStore(); const { fetchTagCounts } = useEmailStore(); const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); const [isMigrating, setIsMigrating] = useState(false); const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const existingIds = emailKeywords.map((k) => k.id); const handleDragStart = (index: number) => { setDragIndex(index); }; const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); if (index !== dragOverIndex) setDragOverIndex(index); }; const handleDrop = (index: number) => { if (dragIndex === null || dragIndex === index) { setDragIndex(null); setDragOverIndex(null); return; } const reordered = [...emailKeywords]; const [moved] = reordered.splice(dragIndex, 1); reordered.splice(index, 0, moved); reorderKeywords(reordered); setDragIndex(null); setDragOverIndex(null); }; const handleDragEnd = () => { setDragIndex(null); setDragOverIndex(null); }; const handleAdd = (keyword: KeywordDefinition) => { addKeyword(keyword); setIsAdding(false); }; const handleEdit = async (keyword: KeywordDefinition) => { const oldId = editingId; if (!oldId) return; const idChanged = oldId !== keyword.id; if (idChanged && client) { setIsMigrating(true); try { const oldJmapKeyword = `$label:${oldId}`; const newJmapKeyword = `$label:${keyword.id}`; await client.migrateKeyword(oldJmapKeyword, newJmapKeyword); renameKeyword(oldId, keyword); fetchTagCounts(client); } catch (error) { console.error("Failed to migrate keyword:", error); const toastModule = await import('sonner'); toastModule.toast.error(t("migration_error")); setIsMigrating(false); return; } setIsMigrating(false); } else { updateKeyword(oldId, { label: keyword.label, color: keyword.color }); } setEditingId(null); }; const handleDelete = (id: string) => { removeKeyword(id); }; const handleResetDefaults = () => { reorderKeywords(DEFAULT_KEYWORDS); }; return ( updateSetting("nestedTags", checked)} />
{isMigrating && (
{t("migrating")}
)} {emailKeywords.map((keyword, index) => editingId === keyword.id ? ( id !== keyword.id)} nestedTags={nestedTags} onSave={handleEdit} onCancel={() => setEditingId(null)} /> ) : ( { setEditingId(keyword.id); setIsAdding(false); }} onDelete={() => handleDelete(keyword.id)} onDragStart={() => handleDragStart(index)} onDragOver={(e) => handleDragOver(e, index)} onDrop={() => handleDrop(index)} onDragEnd={handleDragEnd} isDragOver={dragOverIndex === index && dragIndex !== index} isDragging={dragIndex === index} /> ) )} {isAdding ? ( setIsAdding(false)} /> ) : (
)}
); }