464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
useSettingsStore,
|
|
KEYWORD_PALETTE,
|
|
DEFAULT_KEYWORDS,
|
|
getKeywordVisibility,
|
|
type KeywordDefinition,
|
|
type KeywordVisibility,
|
|
} 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 (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{PALETTE_KEYS.map((colorKey) => (
|
|
<button
|
|
key={colorKey}
|
|
type="button"
|
|
onClick={() => onChange(colorKey)}
|
|
className={cn(
|
|
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
KEYWORD_PALETTE[colorKey].dot,
|
|
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
|
)}
|
|
aria-label={colorKey}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KeywordRow({
|
|
keyword,
|
|
keywords,
|
|
nestedTags,
|
|
onEdit,
|
|
onDelete,
|
|
onVisibilityChange,
|
|
onDragStart,
|
|
onDragOver,
|
|
onDrop,
|
|
onDragEnd,
|
|
isDragOver,
|
|
isDragging,
|
|
}: {
|
|
keyword: KeywordDefinition;
|
|
keywords: KeywordDefinition[];
|
|
nestedTags: boolean;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
onVisibilityChange: (visibility: KeywordVisibility) => 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);
|
|
const visibilityOptions = [
|
|
{ value: "show", label: t("visibility.show") },
|
|
{ value: "unread", label: t("visibility.unread") },
|
|
{ value: "hide", label: t("visibility.hide") },
|
|
];
|
|
|
|
return (
|
|
<div
|
|
draggable
|
|
onDragStart={onDragStart}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
onDragEnd={onDragEnd}
|
|
className={cn(
|
|
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
|
|
isDragging ? "opacity-40" : "opacity-100",
|
|
isDragOver ? "border-primary" : "border-border"
|
|
)}
|
|
>
|
|
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
|
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
|
<span
|
|
ref={nameRef}
|
|
className="flex-1 min-w-0 text-sm font-medium truncate"
|
|
title={formatKeyword(keyword.id, keywords, nestedTags)}
|
|
>
|
|
{shortenedName}
|
|
</span>
|
|
<span
|
|
ref={keywordRef}
|
|
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
|
|
title={KEYWORD_PREFIX + keyword.id}
|
|
>
|
|
{shortenedKeyword}
|
|
</span>
|
|
<Select
|
|
value={getKeywordVisibility(keyword)}
|
|
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
|
|
options={visibilityOptions}
|
|
ariaLabel={t("visibility_field")}
|
|
className="text-xs py-1"
|
|
/>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
title={t("edit")}
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={hasChildren}
|
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
title={hasChildren ? t("has_children_delete") : t("delete")}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
|
{nestedTags && (
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("parent_field")}
|
|
</label>
|
|
<Select
|
|
value={parentId}
|
|
onChange={setParentId}
|
|
options={parentOptions}
|
|
disabled={isLocked}
|
|
ariaLabel={t("parent_field")}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
{t("label_field")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={label}
|
|
onChange={(e) => 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 && (
|
|
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
|
|
{KEYWORD_PREFIX + normalizedId}
|
|
</p>
|
|
)}
|
|
{isLocked && (
|
|
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
|
|
)}
|
|
{isDuplicate && (
|
|
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
|
)}
|
|
{isTooLong && (
|
|
<p className="text-xs text-destructive mt-1">
|
|
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
|
{t("color_field")}
|
|
</label>
|
|
<KeywordColorPicker value={color} onChange={setColor} />
|
|
</div>
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
{t("cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!isValid}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
>
|
|
<Check className="w-3.5 h-3.5" />
|
|
{isEditing ? t("save") : t("add")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [isMigrating, setIsMigrating] = useState(false);
|
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(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 handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
|
updateKeyword(id, { visibility });
|
|
};
|
|
|
|
const handleResetDefaults = () => {
|
|
reorderKeywords(DEFAULT_KEYWORDS);
|
|
};
|
|
|
|
return (
|
|
<SettingsSection title={t("title")} description={t("description")}>
|
|
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
|
|
<ToggleSwitch
|
|
checked={nestedTags}
|
|
onChange={(checked) => updateSetting("nestedTags", checked)}
|
|
/>
|
|
</SettingItem>
|
|
|
|
<div className="space-y-2">
|
|
{isMigrating && (
|
|
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
{t("migrating")}
|
|
</div>
|
|
)}
|
|
{emailKeywords.map((keyword, index) =>
|
|
editingId === keyword.id ? (
|
|
<KeywordEditForm
|
|
key={keyword.id}
|
|
initial={keyword}
|
|
keywords={emailKeywords}
|
|
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
|
nestedTags={nestedTags}
|
|
onSave={handleEdit}
|
|
onCancel={() => setEditingId(null)}
|
|
/>
|
|
) : (
|
|
<KeywordRow
|
|
key={keyword.id}
|
|
keyword={keyword}
|
|
keywords={emailKeywords}
|
|
nestedTags={nestedTags}
|
|
onEdit={() => {
|
|
setEditingId(keyword.id);
|
|
setIsAdding(false);
|
|
}}
|
|
onDelete={() => handleDelete(keyword.id)}
|
|
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
|
onDragStart={() => handleDragStart(index)}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDrop={() => handleDrop(index)}
|
|
onDragEnd={handleDragEnd}
|
|
isDragOver={dragOverIndex === index && dragIndex !== index}
|
|
isDragging={dragIndex === index}
|
|
/>
|
|
)
|
|
)}
|
|
|
|
{isAdding ? (
|
|
<KeywordEditForm
|
|
keywords={emailKeywords}
|
|
existingIds={existingIds}
|
|
nestedTags={nestedTags}
|
|
onSave={handleAdd}
|
|
onCancel={() => setIsAdding(false)}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsAdding(true);
|
|
setEditingId(null);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-dashed border-border hover:border-primary hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
{t("add_keyword")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleResetDefaults}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
{t("reset_defaults")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|