aa462f9f52
Update project name from teaboy-os to tx-os and remove obsolete services.
746 lines
23 KiB
TypeScript
746 lines
23 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "wouter";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
StickyNote,
|
|
Pin,
|
|
Archive,
|
|
ArchiveRestore,
|
|
Trash2,
|
|
Palette,
|
|
Tag,
|
|
Search,
|
|
Plus,
|
|
X,
|
|
Check,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
type Note,
|
|
type NoteLabel,
|
|
NOTE_COLORS,
|
|
colorBg,
|
|
useCreateLabel,
|
|
useCreateNote,
|
|
useDeleteLabel,
|
|
useDeleteNote,
|
|
useNoteLabels,
|
|
useNotes,
|
|
useUpdateLabel,
|
|
useUpdateNote,
|
|
} from "@/lib/notes-api";
|
|
|
|
export default function NotesPage() {
|
|
const { t, i18n } = useTranslation();
|
|
const [, setLocation] = useLocation();
|
|
const isRtl = i18n.language === "ar";
|
|
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
|
|
|
const [view, setView] = useState<"active" | "archived">("active");
|
|
const [search, setSearch] = useState("");
|
|
const [labelFilter, setLabelFilter] = useState<number | null>(null);
|
|
const [editing, setEditing] = useState<Note | null>(null);
|
|
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false);
|
|
|
|
const { data: notes = [], isLoading } = useNotes(view === "archived");
|
|
const { data: labels = [] } = useNoteLabels();
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return notes.filter((n) => {
|
|
if (labelFilter && !n.labelIds.includes(labelFilter)) return false;
|
|
if (!q) return true;
|
|
return (
|
|
n.title.toLowerCase().includes(q) ||
|
|
n.content.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [notes, search, labelFilter]);
|
|
|
|
const pinned = filtered.filter((n) => n.isPinned);
|
|
const others = filtered.filter((n) => !n.isPinned);
|
|
|
|
return (
|
|
<div className="min-h-screen os-bg flex flex-col" dir={isRtl ? "rtl" : "ltr"}>
|
|
{/* Header */}
|
|
<div className="glass-panel border-b border-slate-200/70 px-4 py-3 sticky top-0 z-20">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button
|
|
onClick={() => setLocation("/")}
|
|
className="p-2 rounded-xl hover:bg-slate-100/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={t("common.back", "Back")}
|
|
>
|
|
<BackIcon size={20} />
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<StickyNote size={20} className="text-amber-500" />
|
|
<h1 className="text-lg font-semibold text-foreground">
|
|
{t("notes.title", "Notes")}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-[180px] max-w-md ms-auto relative">
|
|
<Search
|
|
size={16}
|
|
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
style={isRtl ? { right: 10 } : { left: 10 }}
|
|
/>
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("notes.searchPlaceholder", "Search notes")}
|
|
className={isRtl ? "pe-9" : "ps-9"}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setLabelsDialogOpen(true)}
|
|
>
|
|
<Tag size={16} className="me-1" />
|
|
{t("notes.labels", "Labels")}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tabs + label chips */}
|
|
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
|
<div className="inline-flex rounded-xl bg-slate-100/70 p-1">
|
|
<button
|
|
onClick={() => setView("active")}
|
|
className={`px-3 py-1 text-sm rounded-lg transition ${
|
|
view === "active"
|
|
? "bg-white shadow-sm text-foreground"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{t("notes.tabs.active", "Notes")}
|
|
</button>
|
|
<button
|
|
onClick={() => setView("archived")}
|
|
className={`px-3 py-1 text-sm rounded-lg transition ${
|
|
view === "archived"
|
|
? "bg-white shadow-sm text-foreground"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{t("notes.tabs.archived", "Archived")}
|
|
</button>
|
|
</div>
|
|
{labels.length > 0 && (
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<button
|
|
onClick={() => setLabelFilter(null)}
|
|
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
|
labelFilter === null
|
|
? "bg-foreground text-background border-foreground"
|
|
: "border-slate-300/60 text-muted-foreground hover:border-foreground"
|
|
}`}
|
|
>
|
|
{t("notes.allLabels", "All")}
|
|
</button>
|
|
{labels.map((l) => (
|
|
<button
|
|
key={l.id}
|
|
onClick={() => setLabelFilter(l.id)}
|
|
className={`text-xs px-2.5 py-1 rounded-full border transition flex items-center gap-1 ${
|
|
labelFilter === l.id
|
|
? "bg-foreground text-background border-foreground"
|
|
: "border-slate-300/60 text-muted-foreground hover:border-foreground"
|
|
}`}
|
|
>
|
|
<Tag size={11} /> {l.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto">
|
|
{view === "active" && (
|
|
<Composer labels={labels} />
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
|
{t("common.loading", "Loading...")}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
|
|
<StickyNote size={48} className="opacity-30" />
|
|
<span>
|
|
{view === "archived"
|
|
? t("notes.emptyArchived", "No archived notes")
|
|
: t("notes.empty", "Your notes will appear here")}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6 mt-6">
|
|
{pinned.length > 0 && (
|
|
<Section
|
|
title={t("notes.pinned", "Pinned")}
|
|
notes={pinned}
|
|
labels={labels}
|
|
onEdit={setEditing}
|
|
/>
|
|
)}
|
|
<Section
|
|
title={pinned.length > 0 ? t("notes.others", "Others") : ""}
|
|
notes={others}
|
|
labels={labels}
|
|
onEdit={setEditing}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{editing && (
|
|
<EditNoteDialog
|
|
note={editing}
|
|
labels={labels}
|
|
onClose={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
|
|
<LabelsDialog
|
|
open={labelsDialogOpen}
|
|
onClose={() => setLabelsDialogOpen(false)}
|
|
labels={labels}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
notes,
|
|
labels,
|
|
onEdit,
|
|
}: {
|
|
title: string;
|
|
notes: Note[];
|
|
labels: NoteLabel[];
|
|
onEdit: (n: Note) => void;
|
|
}) {
|
|
if (notes.length === 0) return null;
|
|
return (
|
|
<div>
|
|
{title && (
|
|
<h2 className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
|
|
{title}
|
|
</h2>
|
|
)}
|
|
<div
|
|
className="gap-3 [column-fill:_balance]"
|
|
style={{ columnWidth: 230 }}
|
|
>
|
|
{notes.map((n) => (
|
|
<NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NoteCard({
|
|
note,
|
|
labels,
|
|
onEdit,
|
|
}: {
|
|
note: Note;
|
|
labels: NoteLabel[];
|
|
onEdit: (n: Note) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const update = useUpdateNote();
|
|
const del = useDeleteNote();
|
|
const noteLabels = labels.filter((l) => note.labelIds.includes(l.id));
|
|
|
|
return (
|
|
<div
|
|
className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group ${colorBg(
|
|
note.color,
|
|
)}`}
|
|
onClick={() => onEdit(note)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
{note.title && (
|
|
<div className="font-semibold text-sm text-foreground break-words">
|
|
{note.title}
|
|
</div>
|
|
)}
|
|
{note.content && (
|
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
|
|
{note.content}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
update.mutate({ id: note.id, isPinned: !note.isPinned });
|
|
}}
|
|
className={`shrink-0 p-1 rounded-md hover:bg-black/5 ${
|
|
note.isPinned ? "text-amber-600" : "text-muted-foreground opacity-0 group-hover:opacity-100"
|
|
}`}
|
|
aria-label={t("notes.pin", "Pin")}
|
|
>
|
|
<Pin size={16} className={note.isPinned ? "fill-amber-500" : ""} />
|
|
</button>
|
|
</div>
|
|
|
|
{noteLabels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{noteLabels.map((l) => (
|
|
<Badge key={l.id} variant="secondary" className="text-[10px] py-0 px-1.5">
|
|
{l.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ColorPicker
|
|
value={note.color}
|
|
onChange={(c) => update.mutate({ id: note.id, color: c })}
|
|
/>
|
|
<LabelMenu
|
|
labels={labels}
|
|
selected={note.labelIds}
|
|
onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })}
|
|
/>
|
|
<button
|
|
onClick={() =>
|
|
update.mutate({ id: note.id, isArchived: !note.isArchived })
|
|
}
|
|
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
|
aria-label={
|
|
note.isArchived
|
|
? t("notes.unarchive", "Unarchive")
|
|
: t("notes.archive", "Archive")
|
|
}
|
|
>
|
|
{note.isArchived ? <ArchiveRestore size={15} /> : <Archive size={15} />}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(t("notes.deleteConfirm", "Delete this note?"))) {
|
|
del.mutate(note.id);
|
|
}
|
|
}}
|
|
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-destructive"
|
|
aria-label={t("notes.delete", "Delete")}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ColorPicker({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (c: string) => void;
|
|
}) {
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
|
aria-label="Color"
|
|
>
|
|
<Palette size={15} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-2">
|
|
<div className="grid grid-cols-5 gap-1.5">
|
|
{NOTE_COLORS.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => onChange(c.id)}
|
|
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
|
|
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
|
|
}`}
|
|
aria-label={c.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function LabelMenu({
|
|
labels,
|
|
selected,
|
|
onChange,
|
|
}: {
|
|
labels: NoteLabel[];
|
|
selected: number[];
|
|
onChange: (ids: number[]) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const create = useCreateLabel();
|
|
const [name, setName] = useState("");
|
|
|
|
const toggle = (id: number) => {
|
|
if (selected.includes(id)) onChange(selected.filter((x) => x !== id));
|
|
else onChange([...selected, id]);
|
|
};
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
|
aria-label="Labels"
|
|
>
|
|
<Tag size={15} />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 p-2">
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{labels.length === 0 && (
|
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
|
{t("notes.noLabelsYet", "No labels yet")}
|
|
</div>
|
|
)}
|
|
{labels.map((l) => (
|
|
<button
|
|
key={l.id}
|
|
onClick={() => toggle(l.id)}
|
|
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Tag size={12} /> {l.name}
|
|
</span>
|
|
{selected.includes(l.id) && <Check size={14} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 flex gap-1 border-t pt-2">
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("notes.newLabel", "New label")}
|
|
className="h-8 text-sm"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && name.trim()) {
|
|
create.mutate(name.trim(), { onSuccess: () => setName("") });
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={!name.trim()}
|
|
onClick={() =>
|
|
create.mutate(name.trim(), { onSuccess: () => setName("") })
|
|
}
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function Composer({ labels }: { labels: NoteLabel[] }) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const [title, setTitle] = useState("");
|
|
const [content, setContent] = useState("");
|
|
const [color, setColor] = useState("default");
|
|
const [labelIds, setLabelIds] = useState<number[]>([]);
|
|
const create = useCreateNote();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onClick = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) {
|
|
save();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onClick);
|
|
return () => document.removeEventListener("mousedown", onClick);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, title, content, color, labelIds]);
|
|
|
|
const reset = () => {
|
|
setTitle("");
|
|
setContent("");
|
|
setColor("default");
|
|
setLabelIds([]);
|
|
setOpen(false);
|
|
};
|
|
|
|
const save = () => {
|
|
if (!title.trim() && !content.trim()) {
|
|
reset();
|
|
return;
|
|
}
|
|
create.mutate(
|
|
{ title: title.trim(), content: content.trim(), color, labelIds },
|
|
{ onSuccess: reset },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
|
|
color,
|
|
)}`}
|
|
>
|
|
{open ? (
|
|
<>
|
|
<Input
|
|
autoFocus
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t("notes.titlePlaceholder", "Title")}
|
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold"
|
|
/>
|
|
<Textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 mt-1 min-h-[80px] resize-none"
|
|
/>
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<ColorPicker value={color} onChange={setColor} />
|
|
<LabelMenu
|
|
labels={labels}
|
|
selected={labelIds}
|
|
onChange={setLabelIds}
|
|
/>
|
|
<div className="ms-auto">
|
|
<Button size="sm" variant="ghost" onClick={save}>
|
|
{t("notes.save", "Save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="w-full text-start text-muted-foreground py-1 px-1"
|
|
>
|
|
{t("notes.takeNote", "Take a note...")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditNoteDialog({
|
|
note,
|
|
labels,
|
|
onClose,
|
|
}: {
|
|
note: Note;
|
|
labels: NoteLabel[];
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [title, setTitle] = useState(note.title);
|
|
const [content, setContent] = useState(note.content);
|
|
const [color, setColor] = useState(note.color);
|
|
const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
|
|
const update = useUpdateNote();
|
|
|
|
const save = () => {
|
|
update.mutate(
|
|
{ id: note.id, title: title.trim(), content: content.trim(), color, labelIds },
|
|
{ onSuccess: onClose },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && save()}>
|
|
<DialogContent
|
|
className={`max-w-lg ${colorBg(color)}`}
|
|
onInteractOutside={(e) => {
|
|
e.preventDefault();
|
|
save();
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">
|
|
{t("notes.editTitle", "Edit note")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t("notes.titlePlaceholder", "Title")}
|
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold text-base"
|
|
/>
|
|
<Textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
|
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
|
|
/>
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<ColorPicker value={color} onChange={setColor} />
|
|
<LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
|
|
<div className="ms-auto">
|
|
<Button size="sm" onClick={save}>
|
|
{t("notes.save", "Save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function LabelsDialog({
|
|
open,
|
|
onClose,
|
|
labels,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
labels: NoteLabel[];
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [name, setName] = useState("");
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [editingName, setEditingName] = useState("");
|
|
const create = useCreateLabel();
|
|
const update = useUpdateLabel();
|
|
const del = useDeleteLabel();
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("notes.manageLabels", "Manage labels")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("notes.newLabel", "New label")}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && name.trim()) {
|
|
create.mutate(name.trim(), { onSuccess: () => setName("") });
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
disabled={!name.trim()}
|
|
onClick={() =>
|
|
create.mutate(name.trim(), { onSuccess: () => setName("") })
|
|
}
|
|
>
|
|
<Plus size={16} />
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
|
{labels.length === 0 && (
|
|
<div className="text-sm text-muted-foreground py-4 text-center">
|
|
{t("notes.noLabelsYet", "No labels yet")}
|
|
</div>
|
|
)}
|
|
{labels.map((l) => (
|
|
<div
|
|
key={l.id}
|
|
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-100 group"
|
|
>
|
|
<Tag size={14} className="text-muted-foreground" />
|
|
{editingId === l.id ? (
|
|
<>
|
|
<Input
|
|
value={editingName}
|
|
onChange={(e) => setEditingName(e.target.value)}
|
|
autoFocus
|
|
className="h-7"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && editingName.trim()) {
|
|
update.mutate(
|
|
{ id: l.id, name: editingName.trim() },
|
|
{ onSuccess: () => setEditingId(null) },
|
|
);
|
|
}
|
|
if (e.key === "Escape") setEditingId(null);
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => {
|
|
if (editingName.trim())
|
|
update.mutate(
|
|
{ id: l.id, name: editingName.trim() },
|
|
{ onSuccess: () => setEditingId(null) },
|
|
);
|
|
}}
|
|
className="p-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingId(null)}
|
|
className="p-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span
|
|
className="flex-1 text-sm cursor-pointer"
|
|
onClick={() => {
|
|
setEditingId(l.id);
|
|
setEditingName(l.name);
|
|
}}
|
|
>
|
|
{l.name}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(t("notes.deleteLabelConfirm", "Delete this label?"))) {
|
|
del.mutate(l.id);
|
|
}
|
|
}}
|
|
className="p-1 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|