Files
TX/artifacts/tx-os/src/pages/notes.tsx
T
riyadhafraa 6352cbf844 Task #402: Convert Notes into in-app messaging (independence fix)
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.
2026-05-05 14:53:30 +00:00

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>
);
}