6352cbf844
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an Inbox
with sender name, color, Read/Unread badge, and inline reply. Sender sees
Sent Notes with per-recipient status. Sender's and recipient's copies must
be INDEPENDENT, with proper backend access checks (admin sees everything),
realtime updates, and full i18n + RTL.
Initial implementation review FAILED because the recipient view still read
from the sender's notes table, so sender edits/deletes mutated recipient
copies. This commit completes the fix:
- Schema (lib/db/src/schema/notes.ts): added immutable snapshot columns
(title/content/color) on note_recipients; dropped the FK on
note_recipients.note_id and note_replies.note_id and made them plain
integers so recipient threads survive the sender deleting their note.
- Routes (artifacts/api-server/src/routes/notes.ts):
- /notes/received and /notes/:id/thread now serve the recipient snapshot
(sender/admin still see the live note).
- /notes/:id/reply derives the owner from note_recipients.senderUserId
so it works after sender deletion, clears archivedAt, and bumps
status to "replied".
- /notes/sent filters out null noteIds.
- Tests (artifacts/api-server/tests/notes-share.test.mjs): added
snapshot-independence test (sender edit + delete must not mutate
recipient copy; thread + reply still work after sender delete) and
archived-reply-clears-archivedAt test. All 5 backend tests pass; the
existing notes-inbox e2e still passes.
- Architect review: PASS (conditional only on the schema migration being
applied, which has been pushed via `pnpm --filter @workspace/db push`).
Pre-existing executive-meetings TS errors and the failing `test` workflow
are unrelated to this task.
1353 lines
42 KiB
TypeScript
1353 lines
42 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,
|
|
Send,
|
|
Inbox,
|
|
Mail,
|
|
MessageCircle,
|
|
} 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,
|
|
type ReceivedNote,
|
|
type SentNote,
|
|
type SentNoteRecipient,
|
|
type UserSummary,
|
|
NOTE_COLORS,
|
|
colorBg,
|
|
useArchiveReceivedNote,
|
|
useCreateLabel,
|
|
useCreateNote,
|
|
useDeleteLabel,
|
|
useDeleteNote,
|
|
useMarkNoteRead,
|
|
useNoteLabels,
|
|
useNoteThread,
|
|
useNotes,
|
|
useReceivedNotes,
|
|
useReplyToNote,
|
|
useSendNote,
|
|
useSentNotes,
|
|
useUpdateLabel,
|
|
useUpdateNote,
|
|
useUserDirectory,
|
|
userDisplayName,
|
|
} from "@/lib/notes-api";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
|
|
type TabId = "active" | "received" | "sent" | "archived";
|
|
|
|
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<TabId>("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 [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
|
|
const [openThreadId, setOpenThreadId] = useState<number | null>(null);
|
|
|
|
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
|
|
const { data: labels = [] } = useNoteLabels();
|
|
const { data: receivedNotes = [], isLoading: loadingReceived } =
|
|
useReceivedNotes(false);
|
|
const { data: sentNotes = [], isLoading: loadingSent } = useSentNotes();
|
|
|
|
const filteredNotes = 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 filteredReceived = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return receivedNotes;
|
|
return receivedNotes.filter(
|
|
(n) =>
|
|
n.title.toLowerCase().includes(q) ||
|
|
n.content.toLowerCase().includes(q),
|
|
);
|
|
}, [receivedNotes, search]);
|
|
|
|
const filteredSent = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return sentNotes;
|
|
return sentNotes.filter(
|
|
(n) =>
|
|
n.title.toLowerCase().includes(q) ||
|
|
n.content.toLowerCase().includes(q),
|
|
);
|
|
}, [sentNotes, search]);
|
|
|
|
const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length;
|
|
const pinned = filteredNotes.filter((n) => n.isPinned);
|
|
const others = filteredNotes.filter((n) => !n.isPinned);
|
|
|
|
return (
|
|
<div
|
|
className="min-h-screen os-bg flex flex-col"
|
|
dir={isRtl ? "rtl" : "ltr"}
|
|
data-testid="notes-page"
|
|
>
|
|
<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>
|
|
|
|
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
|
<div className="inline-flex rounded-xl bg-slate-100/70 p-1">
|
|
<TabButton
|
|
active={view === "active"}
|
|
onClick={() => setView("active")}
|
|
testId="notes-tab-active"
|
|
>
|
|
<StickyNote size={14} className="me-1 inline-block" />
|
|
{t("notes.tabs.active", "My Notes")}
|
|
</TabButton>
|
|
<TabButton
|
|
active={view === "received"}
|
|
onClick={() => setView("received")}
|
|
testId="notes-tab-received"
|
|
>
|
|
<Inbox size={14} className="me-1 inline-block" />
|
|
{t("notes.tabs.received", "Inbox")}
|
|
{unreadInboxCount > 0 && (
|
|
<span
|
|
className="ms-1 inline-flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] min-w-[18px] h-[18px] px-1"
|
|
data-testid="notes-inbox-unread-badge"
|
|
>
|
|
{unreadInboxCount}
|
|
</span>
|
|
)}
|
|
</TabButton>
|
|
<TabButton
|
|
active={view === "sent"}
|
|
onClick={() => setView("sent")}
|
|
testId="notes-tab-sent"
|
|
>
|
|
<Send size={14} className="me-1 inline-block" />
|
|
{t("notes.tabs.sent", "Sent")}
|
|
</TabButton>
|
|
<TabButton
|
|
active={view === "archived"}
|
|
onClick={() => setView("archived")}
|
|
testId="notes-tab-archived"
|
|
>
|
|
<Archive size={14} className="me-1 inline-block" />
|
|
{t("notes.tabs.archived", "Archived")}
|
|
</TabButton>
|
|
</div>
|
|
{(view === "active" || view === "archived") && 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} />
|
|
{loadingNotes ? (
|
|
<Loading />
|
|
) : filteredNotes.length === 0 ? (
|
|
<Empty icon={<StickyNote size={48} />} text={t("notes.empty", "Your notes will appear here")} />
|
|
) : (
|
|
<div className="space-y-6 mt-6">
|
|
{pinned.length > 0 && (
|
|
<Section
|
|
title={t("notes.pinned", "Pinned")}
|
|
notes={pinned}
|
|
labels={labels}
|
|
onEdit={setEditing}
|
|
onSend={(n) => setSendForNoteId(n.id)}
|
|
/>
|
|
)}
|
|
<Section
|
|
title={pinned.length > 0 ? t("notes.others", "Others") : ""}
|
|
notes={others}
|
|
labels={labels}
|
|
onEdit={setEditing}
|
|
onSend={(n) => setSendForNoteId(n.id)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{view === "archived" && (
|
|
<>
|
|
{loadingNotes ? (
|
|
<Loading />
|
|
) : filteredNotes.length === 0 ? (
|
|
<Empty icon={<StickyNote size={48} />} text={t("notes.emptyArchived", "No archived notes")} />
|
|
) : (
|
|
<div className="mt-2">
|
|
<Section title="" notes={filteredNotes} labels={labels} onEdit={setEditing} onSend={(n) => setSendForNoteId(n.id)} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{view === "received" && (
|
|
<ReceivedList
|
|
loading={loadingReceived}
|
|
notes={filteredReceived}
|
|
onOpen={(id) => setOpenThreadId(id)}
|
|
/>
|
|
)}
|
|
|
|
{view === "sent" && (
|
|
<SentList
|
|
loading={loadingSent}
|
|
notes={filteredSent}
|
|
onOpen={(id) => setOpenThreadId(id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{editing && (
|
|
<EditNoteDialog
|
|
note={editing}
|
|
labels={labels}
|
|
onClose={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
|
|
<LabelsDialog
|
|
open={labelsDialogOpen}
|
|
onClose={() => setLabelsDialogOpen(false)}
|
|
labels={labels}
|
|
/>
|
|
|
|
{sendForNoteId !== null && (
|
|
<SendNoteDialog
|
|
noteId={sendForNoteId}
|
|
onClose={() => setSendForNoteId(null)}
|
|
/>
|
|
)}
|
|
|
|
{openThreadId !== null && (
|
|
<ThreadDialog
|
|
noteId={openThreadId}
|
|
onClose={() => setOpenThreadId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TabButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
testId,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
testId?: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
data-testid={testId}
|
|
className={`px-3 py-1 text-sm rounded-lg transition inline-flex items-center ${
|
|
active
|
|
? "bg-white shadow-sm text-foreground"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Loading() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
|
{t("common.loading", "Loading...")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Empty({ icon, text }: { icon: React.ReactNode; text: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
|
|
<div className="opacity-30">{icon}</div>
|
|
<span>{text}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
notes,
|
|
labels,
|
|
onEdit,
|
|
onSend,
|
|
}: {
|
|
title: string;
|
|
notes: Note[];
|
|
labels: NoteLabel[];
|
|
onEdit: (n: Note) => void;
|
|
onSend: (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} onSend={onSend} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NoteCard({
|
|
note,
|
|
labels,
|
|
onEdit,
|
|
onSend,
|
|
}: {
|
|
note: Note;
|
|
labels: NoteLabel[];
|
|
onEdit: (n: Note) => void;
|
|
onSend: (n: Note) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const update = useUpdateNote();
|
|
const del = useDeleteNote();
|
|
const noteLabels = labels.filter((l) => note.labelIds.includes(l.id));
|
|
|
|
return (
|
|
<div
|
|
data-testid={`note-card-${note.id}`}
|
|
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={() => onSend(note)}
|
|
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
|
|
aria-label={t("notes.send", "Send")}
|
|
data-testid={`note-card-send-${note.id}`}
|
|
>
|
|
<Send size={15} />
|
|
</button>
|
|
<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 ReceivedList({
|
|
loading,
|
|
notes,
|
|
onOpen,
|
|
}: {
|
|
loading: boolean;
|
|
notes: ReceivedNote[];
|
|
onOpen: (id: number) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
if (loading) return <Loading />;
|
|
if (notes.length === 0) {
|
|
return (
|
|
<Empty
|
|
icon={<Inbox size={48} />}
|
|
text={t("notes.emptyReceived", "No notes have been sent to you yet")}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
className="gap-3 mt-2 [column-fill:_balance]"
|
|
style={{ columnWidth: 260 }}
|
|
data-testid="notes-received-list"
|
|
>
|
|
{notes.map((n) => (
|
|
<button
|
|
key={n.id}
|
|
onClick={() => onOpen(n.id)}
|
|
data-testid={`received-note-card-${n.id}`}
|
|
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
|
|
n.color,
|
|
)}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
|
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
|
|
<Mail size={12} className="shrink-0" />
|
|
<span className="truncate">
|
|
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
|
|
</span>
|
|
</div>
|
|
<StatusBadge status={n.status} />
|
|
</div>
|
|
{n.title && (
|
|
<div className="font-semibold text-sm text-foreground break-words">
|
|
{n.title}
|
|
</div>
|
|
)}
|
|
{n.content && (
|
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
|
{n.content}
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SentList({
|
|
loading,
|
|
notes,
|
|
onOpen,
|
|
}: {
|
|
loading: boolean;
|
|
notes: SentNote[];
|
|
onOpen: (id: number) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
if (loading) return <Loading />;
|
|
if (notes.length === 0) {
|
|
return (
|
|
<Empty
|
|
icon={<Send size={48} />}
|
|
text={t("notes.emptySent", "You haven't sent any notes yet")}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
className="gap-3 mt-2 [column-fill:_balance]"
|
|
style={{ columnWidth: 260 }}
|
|
data-testid="notes-sent-list"
|
|
>
|
|
{notes.map((n) => (
|
|
<button
|
|
key={n.id}
|
|
onClick={() => onOpen(n.id)}
|
|
data-testid={`sent-note-card-${n.id}`}
|
|
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
|
|
n.color,
|
|
)}`}
|
|
>
|
|
{n.title && (
|
|
<div className="font-semibold text-sm text-foreground break-words">
|
|
{n.title}
|
|
</div>
|
|
)}
|
|
{n.content && (
|
|
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
|
|
{n.content}
|
|
</div>
|
|
)}
|
|
<div className="text-[11px] text-muted-foreground mt-2">
|
|
{t("notes.to", "To:")}{" "}
|
|
<span className="text-foreground/80">
|
|
{n.recipients
|
|
.map((r) => userDisplayName(r.recipient, lang))
|
|
.join("، ")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{n.recipients.map((r) => (
|
|
<span
|
|
key={r.id}
|
|
data-testid={`sent-recipient-status-${n.id}-${r.recipientUserId}`}
|
|
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-white/60 border border-black/5"
|
|
>
|
|
<span className="truncate max-w-[80px]">
|
|
{userDisplayName(r.recipient, lang)}
|
|
</span>
|
|
<StatusDot status={r.status} />
|
|
</span>
|
|
))}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const { t } = useTranslation();
|
|
const map: Record<string, string> = {
|
|
unread: "bg-rose-500/15 text-rose-700",
|
|
read: "bg-slate-200 text-slate-700",
|
|
replied: "bg-emerald-500/15 text-emerald-700",
|
|
archived: "bg-slate-200 text-slate-500",
|
|
};
|
|
return (
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full ${map[status] ?? map.read}`}
|
|
data-testid={`status-badge-${status}`}
|
|
>
|
|
{t(`notes.status.${status}`, status)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusDot({ status }: { status: string }) {
|
|
const map: Record<string, string> = {
|
|
unread: "bg-rose-500",
|
|
read: "bg-slate-400",
|
|
replied: "bg-emerald-500",
|
|
archived: "bg-slate-300",
|
|
};
|
|
return (
|
|
<span
|
|
className={`inline-block w-1.5 h-1.5 rounded-full ${map[status] ?? map.read}`}
|
|
aria-label={status}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SendNoteDialog({
|
|
noteId,
|
|
onClose,
|
|
}: {
|
|
noteId: number;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const { user } = useAuth();
|
|
const { data: directory = [], isLoading } = useUserDirectory();
|
|
const send = useSendNote();
|
|
const [search, setSearch] = useState("");
|
|
const [picked, setPicked] = useState<Set<number>>(new Set());
|
|
|
|
const candidates = useMemo(() => {
|
|
const me = user?.id;
|
|
return directory.filter((u) => u.id !== me);
|
|
}, [directory, user]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return candidates;
|
|
return candidates.filter((u) =>
|
|
[u.username, u.displayNameAr, u.displayNameEn]
|
|
.filter(Boolean)
|
|
.some((s) => (s as string).toLowerCase().includes(q)),
|
|
);
|
|
}, [candidates, search]);
|
|
|
|
const toggle = (id: number) => {
|
|
setPicked((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const submit = () => {
|
|
if (picked.size === 0) return;
|
|
send.mutate(
|
|
{ id: noteId, recipientUserIds: Array.from(picked) },
|
|
{ onSuccess: onClose },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-w-md" data-testid="send-note-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="relative">
|
|
<Search
|
|
size={14}
|
|
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
style={lang === "ar" ? { right: 10 } : { left: 10 }}
|
|
/>
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
|
className={lang === "ar" ? "pe-9" : "ps-9"}
|
|
data-testid="send-note-search"
|
|
/>
|
|
</div>
|
|
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
|
|
{isLoading && (
|
|
<div className="p-3 text-sm text-muted-foreground">
|
|
{t("common.loading", "Loading...")}
|
|
</div>
|
|
)}
|
|
{!isLoading && filtered.length === 0 && (
|
|
<div className="p-3 text-sm text-muted-foreground text-center">
|
|
—
|
|
</div>
|
|
)}
|
|
{filtered.map((u) => {
|
|
const checked = picked.has(u.id);
|
|
return (
|
|
<button
|
|
key={u.id}
|
|
onClick={() => toggle(u.id)}
|
|
data-testid={`send-recipient-option-${u.id}`}
|
|
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
|
|
checked ? "bg-primary/5" : ""
|
|
}`}
|
|
>
|
|
<span className="truncate">{userDisplayName(u, lang)}</span>
|
|
{checked && <Check size={14} className="text-primary" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{picked.size > 0 && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("notes.recipientsCount", { count: picked.size })}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t("common.cancel", "Cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={submit}
|
|
disabled={picked.size === 0 || send.isPending}
|
|
data-testid="send-note-submit"
|
|
>
|
|
<Send size={14} className="me-1" />
|
|
{t("notes.send", "Send")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ThreadDialog({
|
|
noteId,
|
|
onClose,
|
|
}: {
|
|
noteId: number;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const { data: thread, isLoading } = useNoteThread(noteId);
|
|
const markRead = useMarkNoteRead();
|
|
const reply = useReplyToNote();
|
|
const archive = useArchiveReceivedNote();
|
|
const [replyText, setReplyText] = useState("");
|
|
const markedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!thread || markedRef.current) return;
|
|
if (!thread.isOwner && !thread.isAdmin && thread.myStatus === "unread") {
|
|
markedRef.current = true;
|
|
markRead.mutate(noteId);
|
|
}
|
|
}, [thread, noteId, markRead]);
|
|
|
|
const submitReply = () => {
|
|
const c = replyText.trim();
|
|
if (!c) return;
|
|
reply.mutate(
|
|
{ id: noteId, content: c },
|
|
{ onSuccess: () => setReplyText("") },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent
|
|
className={`max-w-lg ${thread ? colorBg(thread.color) : ""}`}
|
|
data-testid="note-thread-dialog"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base">
|
|
{thread?.title || t("notes.editTitle", "Edit note")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{isLoading || !thread ? (
|
|
<Loading />
|
|
) : (
|
|
<>
|
|
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
|
|
<Mail size={12} />
|
|
{t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
|
|
</div>
|
|
{thread.content && (
|
|
<div className="text-sm text-foreground/85 whitespace-pre-wrap mt-1">
|
|
{thread.content}
|
|
</div>
|
|
)}
|
|
{(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
|
|
<div className="border-t pt-3">
|
|
<div className="text-xs text-muted-foreground mb-1">
|
|
{t("notes.to", "To:")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{thread.recipients.map((r) => (
|
|
<span
|
|
key={r.id}
|
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-white/70 border border-black/5"
|
|
data-testid={`thread-recipient-${r.recipientUserId}`}
|
|
>
|
|
<span>{userDisplayName(r.recipient, lang)}</span>
|
|
<StatusDot status={r.status} />
|
|
<span className="text-muted-foreground">
|
|
{t(`notes.status.${r.status}`, r.status)}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t pt-3">
|
|
<div className="text-xs text-muted-foreground mb-1.5 flex items-center gap-1">
|
|
<MessageCircle size={12} />
|
|
{t("notes.replies", "Replies")}
|
|
{thread.replies.length > 0 && (
|
|
<span className="text-foreground/70">
|
|
({thread.replies.length})
|
|
</span>
|
|
)}
|
|
</div>
|
|
{thread.replies.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
{t("notes.noRepliesYet", "No replies yet")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{thread.replies.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
data-testid={`thread-reply-${r.id}`}
|
|
className="text-sm bg-white/70 rounded-lg p-2 border border-black/5"
|
|
>
|
|
<div className="text-[11px] text-muted-foreground mb-0.5">
|
|
{userDisplayName(r.sender, lang)}
|
|
</div>
|
|
<div className="whitespace-pre-wrap break-words">
|
|
{r.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!thread.isOwner && thread.myStatus !== null && thread.myStatus !== "archived" && (
|
|
<div className="border-t pt-3 flex flex-col gap-2">
|
|
<Textarea
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
placeholder={t("notes.replyPlaceholder", "Write a reply…")}
|
|
className="min-h-[70px] resize-none"
|
|
data-testid="thread-reply-input"
|
|
/>
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
archive.mutate(
|
|
{ id: noteId, archived: true },
|
|
{ onSuccess: onClose },
|
|
);
|
|
}}
|
|
>
|
|
<Archive size={14} className="me-1" />
|
|
{t("notes.archive", "Archive")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={submitReply}
|
|
disabled={!replyText.trim() || reply.isPending}
|
|
data-testid="thread-reply-submit"
|
|
>
|
|
<Send size={14} className="me-1" />
|
|
{t("notes.sendReply", "Send reply")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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}
|
|
data-testid="notes-composer"
|
|
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"
|
|
data-testid="notes-composer-title"
|
|
/>
|
|
<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"
|
|
data-testid="notes-composer-content"
|
|
/>
|
|
<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} data-testid="notes-composer-save">
|
|
{t("notes.save", "Save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="w-full text-start text-muted-foreground py-1 px-1"
|
|
data-testid="notes-composer-open"
|
|
>
|
|
{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>
|
|
);
|
|
}
|