Files
TX/artifacts/tx-os/src/pages/notes.tsx
T
riyadhafraa ea3092bb2b Task #431: polish Send Note dialog (RTL X + search overlap)
Two visual issues in the "إرسال الملاحظة" dialog:

1. The shadcn DialogContent renders a built-in close (X) button hard
   pinned at `right-4 top-4`. In Arabic the dialog title is
   right-aligned and crashed into the X.
   Fix: add `pr-8` to the SendNoteDialog header so the title row
   reserves physical right-padding matching where the X actually
   lives in both LTR and RTL. (Initially used `pe-8` but corrected to
   `pr-8` after code review noted the close button is pinned with
   physical `right-4`, so a logical end-padding flips to the wrong
   side in RTL.) The shared dialog component is unchanged so other
   dialogs are untouched (out of scope per the task plan).

2. The recipient search field used inline `style={{ right|left: 10 }}`
   for the magnifier and a conditional `pe-9` / `ps-9` based on
   `i18n.language`. In RTL the icon collided with the placeholder.
   Fix: drop the conditional branching — position the icon with the
   logical class `start-2.5` and always pad the input with `ps-9`.
   The icon now sits at the inline-start edge in both LTR and RTL
   with a clear gutter to the placeholder/caret.

Skipped step 3 of the plan (i18n aria-label override on the X). It
required no locale changes and no rendering improvement, so per the
plan's own conditional ("only if step 3 keeps the override; otherwise
no locale changes") it was dropped to keep scope tight.

Verification:
- `pnpm exec tsc --noEmit` clean.
- Playwright: `notes-inbox.spec.mjs` (1/1) and
  `notes-popup-touch-tap.spec.mjs` (2/2) green — both exercise the
  send-note dialog rendering. `notes-popup-on-receive.spec.mjs` runs
  two browser contexts and exceeds the local 120s shell cap; not
  re-run here, but the diff is CSS-only and cannot affect the wire
  flow it covers.
2026-05-07 09:03:28 +00:00

2823 lines
90 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useSearch } from "wouter";
import {
ArrowLeft,
ArrowRight,
StickyNote,
Pin,
Archive,
ArchiveRestore,
Trash2,
Palette,
Tag,
Search,
Plus,
X,
Check,
Send,
Inbox,
Mail,
MessageCircle,
ListTodo,
LayoutGrid,
List as ListIcon,
Folder,
Pencil,
MoreVertical,
ChevronLeft,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { formatDistanceToNow } from "date-fns";
import { ar as dfAr, enUS as dfEn } from "date-fns/locale";
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
type ChecklistItem,
type Note,
type NoteKind,
type NoteLabel,
type NoteThread,
type ReceivedNote,
type SentNote,
type SentNoteRecipient,
type UserSummary,
NOTE_COLORS,
colorBg,
useArchiveReceivedNote,
useCreateLabel,
useCreateNote,
useDeleteLabel,
useDeleteNote,
useMarkNoteRead,
useDeleteFolder,
useMoveNoteToFolder,
useNoteFolders,
useNoteLabels,
useUpdateFolder,
type NoteFolder,
useNoteThread,
useNotes,
useReceivedNotes,
useReplyToNote,
useSendNote,
useSentNotes,
useUpdateLabel,
useUpdateNote,
useUserDirectory,
userDisplayName,
} from "@/lib/notes-api";
import {
FoldersRail,
type FolderSelection,
setDraggingNoteId,
getDraggingNoteId,
registerFolderDropHandler,
highlightTouchDropAt,
clearTouchDropHighlights,
dispatchTouchDropAt,
} from "@/components/notes/folders-rail";
import { useAuth } from "@/contexts/AuthContext";
type TabId = "active" | "received" | "sent" | "archived";
type ViewMode = "grid" | "list";
const VIEW_MODE_STORAGE_KEY = "tx-os.notes.viewMode";
function loadViewMode(): ViewMode {
if (typeof window === "undefined") return "grid";
try {
const v = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY);
return v === "list" ? "list" : "grid";
} catch {
return "grid";
}
}
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 [viewMode, setViewModeState] = useState<ViewMode>(() => loadViewMode());
const setViewMode = useCallback((m: ViewMode) => {
setViewModeState(m);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, m);
} catch {
// localStorage may be unavailable (private mode); ignore.
}
}
}, []);
const [search, setSearch] = useState("");
const [labelFilter, setLabelFilter] = useState<number | null>(null);
const [folderSelection, setFolderSelection] = useState<FolderSelection>({
kind: "all",
});
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 [threadReplyIntent, setThreadReplyIntent] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// When the user changes tab, folder, label filter, or search, the visible
// note set changes — clear selection so stale ids do not leak across views.
useEffect(() => {
setSelectedIds((prev) => (prev.size === 0 ? prev : new Set()));
}, [view, folderSelection, labelFilter, search]);
const toggleSelected = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelected = useCallback(() => setSelectedIds(new Set()), []);
// Allow deep-links like /notes?thread=42&reply=1 (from the incoming-note
// popup) to jump into the inbox tab + thread dialog. Reactive on the
// current query string so this still works when the user is already on
// /notes and the popup just updates the URL.
const queryString = useSearch();
useEffect(() => {
const params = new URLSearchParams(queryString);
const threadParam = params.get("thread");
if (!threadParam) return;
const id = Number(threadParam);
if (!Number.isInteger(id) || id <= 0) return;
const wantsReply = params.get("reply") === "1";
setView("received");
setOpenThreadId(id);
setThreadReplyIntent(wantsReply);
if (typeof window !== "undefined") {
params.delete("thread");
params.delete("reply");
const qs = params.toString();
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
window.history.replaceState(null, "", next);
}
}, [queryString]);
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
const { data: labels = [] } = useNoteLabels();
const { data: folders = [] } = useNoteFolders(view === "archived");
const moveNote = useMoveNoteToFolder();
const showFolders = view === "active" || view === "archived";
// If the user switches tabs (or a folder is deleted) such that the current
// selection no longer exists in this view's folder set, fall back to "all"
// so they don't get stuck on an empty list.
useEffect(() => {
if (folderSelection.kind !== "folder") return;
if (!folders.some((f) => f.id === folderSelection.id)) {
setFolderSelection({ kind: "all" });
}
}, [folders, folderSelection]);
const { data: receivedNotes = [], isLoading: loadingReceived } =
useReceivedNotes(false);
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
useReceivedNotes(true);
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 (folderSelection.kind === "unfiled" && n.folderId !== null) return false;
if (folderSelection.kind === "folder" && n.folderId !== folderSelection.id)
return false;
if (!q) return true;
return (
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
});
}, [notes, search, labelFilter, folderSelection]);
const unfiledCount = notes.filter((n) => n.folderId === null).length;
const handleDropNote = useCallback(
(target: "unfiled" | number) => {
const id = getDraggingNoteId();
setDraggingNoteId(null);
if (id == null) return;
const note = notes.find((n) => n.id === id);
const nextFolderId = target === "unfiled" ? null : target;
if (note && note.folderId === nextFolderId) return;
moveNote.mutate({ id, folderId: nextFolderId });
},
[notes, moveNote],
);
// Register a drop handler so the touch-drag fallback in NoteCard can
// resolve a folder target by hit-testing the rendered rail. Required for
// iPad/touch users where HTML5 drag events do not fire.
useEffect(() => {
registerFolderDropHandler(handleDropNote);
return () => registerFolderDropHandler(null);
}, [handleDropNote]);
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)}
data-testid="notes-manage-labels-open"
>
<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>
)}
{view === "active" && folderSelection.kind === "all" && (
<div
className="ms-auto inline-flex rounded-lg border border-slate-300/60 bg-white/60 p-0.5"
data-testid="notes-view-toggle"
>
<button
onClick={() => setViewMode("grid")}
aria-pressed={viewMode === "grid"}
aria-label={t("notes.viewGrid", "Grid view")}
title={t("notes.viewGrid", "Grid view")}
data-testid="notes-view-grid"
className={`p-1.5 rounded-md transition ${
viewMode === "grid"
? "bg-slate-900 text-white"
: "text-muted-foreground hover:text-foreground"
}`}
>
<LayoutGrid size={14} />
</button>
<button
onClick={() => setViewMode("list")}
aria-pressed={viewMode === "list"}
aria-label={t("notes.viewList", "List view")}
title={t("notes.viewList", "List view")}
data-testid="notes-view-list"
className={`p-1.5 rounded-md transition ${
viewMode === "list"
? "bg-slate-900 text-white"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ListIcon size={14} />
</button>
</div>
)}
</div>
</div>
<div className="flex-1 px-4 py-6 max-w-6xl w-full mx-auto flex gap-6">
{showFolders && (
<FoldersRail
folders={folders}
selected={folderSelection}
onSelect={setFolderSelection}
unfiledCount={unfiledCount}
totalCount={notes.length}
onDropNote={handleDropNote}
mode={view === "active" && folderSelection.kind === "all" ? "chips-only" : "rail"}
/>
)}
<div className="flex-1 min-w-0">
{view === "active" && (
<>
{folderSelection.kind === "all" && (
<FoldersBrowser
folders={folders}
viewMode={viewMode}
onSelect={setFolderSelection}
onDropNote={handleDropNote}
/>
)}
{folderSelection.kind !== "all" && (
<button
type="button"
onClick={() => setFolderSelection({ kind: "all" })}
className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
data-testid="notes-folders-back"
>
<ChevronLeft size={14} className="rtl:rotate-180" />
<span>
{t("notes.folders.backToFolders", "Back to folders")}
</span>
<span className="text-foreground/80 font-medium ms-1">
{folderSelection.kind === "unfiled"
? t("notes.folders.unfiled", "Unfiled")
: folders.find((f) => f.id === folderSelection.id)?.name ||
""}
</span>
</button>
)}
<Composer labels={labels} folderSelection={folderSelection} />
{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-4">
{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" && (
<ArchivedView
loadingMine={loadingNotes}
mine={filteredNotes}
labels={labels}
onEdit={setEditing}
onSend={(n) => setSendForNoteId(n.id)}
loadingInbox={loadingArchivedReceived}
archivedReceived={archivedReceivedNotes.filter((n) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
);
})}
onOpenThread={(id) => setOpenThreadId(id)}
/>
)}
{view === "received" && (
<ReceivedList
loading={loadingReceived}
notes={filteredReceived}
onOpen={(id) => setOpenThreadId(id)}
/>
)}
{view === "sent" && (
<SentList
loading={loadingSent}
notes={filteredSent}
onOpen={(id) => setOpenThreadId(id)}
/>
)}
</div>
</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}
autoFocusReply={threadReplyIntent}
onClose={() => {
setOpenThreadId(null);
setThreadReplyIntent(false);
}}
/>
)}
</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));
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
// Touch fallback for mobile / iPad: a long-press starts a drag, touchmove
// hit-tests folder drop targets, touchend resolves the drop.
const touchStateRef = useRef<{
longPressTimer: number | null;
dragging: boolean;
suppressClick: boolean;
startX: number;
startY: number;
}>({ longPressTimer: null, dragging: false, suppressClick: false, startX: 0, startY: 0 });
// Pixels of finger travel allowed before we treat the touch as a scroll
// (and cancel the pending long-press) instead of an emerging drag.
const TOUCH_SLOP_PX = 10;
const cancelTouchTimer = () => {
const s = touchStateRef.current;
if (s.longPressTimer !== null) {
window.clearTimeout(s.longPressTimer);
s.longPressTimer = null;
}
};
return (
<>
<div
data-testid={`note-card-${note.id}`}
data-folder-id={note.folderId ?? ""}
draggable
onDragStart={(e) => {
setDraggingNoteId(note.id);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", `note:${note.id}`);
}}
onDragEnd={() => setDraggingNoteId(null)}
onTouchStart={(e) => {
const touch = e.touches[0];
if (!touch) return;
const s = touchStateRef.current;
cancelTouchTimer();
s.dragging = false;
s.suppressClick = false;
s.startX = touch.clientX;
s.startY = touch.clientY;
s.longPressTimer = window.setTimeout(() => {
s.dragging = true;
s.suppressClick = true;
setDraggingNoteId(note.id);
highlightTouchDropAt(s.startX, s.startY);
}, 350);
}}
onTouchMove={(e) => {
const s = touchStateRef.current;
const touch = e.touches[0];
if (!touch) return;
if (!s.dragging) {
// Only cancel the pending long-press once the finger has moved
// beyond the slop threshold — small jitter while the user is
// pressing should not be treated as a scroll.
const dx = touch.clientX - s.startX;
const dy = touch.clientY - s.startY;
if (dx * dx + dy * dy > TOUCH_SLOP_PX * TOUCH_SLOP_PX) {
cancelTouchTimer();
}
return;
}
e.preventDefault();
highlightTouchDropAt(touch.clientX, touch.clientY);
}}
onTouchEnd={(e) => {
const s = touchStateRef.current;
cancelTouchTimer();
if (!s.dragging) return;
const touch = e.changedTouches[0];
s.dragging = false;
clearTouchDropHighlights();
if (touch) dispatchTouchDropAt(touch.clientX, touch.clientY);
setDraggingNoteId(null);
}}
onTouchCancel={() => {
cancelTouchTimer();
touchStateRef.current.dragging = false;
clearTouchDropHighlights();
setDraggingNoteId(null);
}}
className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group touch-manipulation ${colorBg(
note.color,
)}`}
onClick={() => {
if (touchStateRef.current.suppressClick) {
touchStateRef.current.suppressClick = false;
return;
}
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.kind === "checklist" ? (
<ChecklistView
items={note.items ?? []}
testIdPrefix={`note-card-checklist-${note.id}`}
className="mt-1"
onToggle={(itemId, done) => {
const next = (note.items ?? []).map((it) =>
it.id === itemId ? { ...it, done } : it,
);
update.mutate({ id: note.id, kind: "checklist", items: next });
}}
/>
) : (
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 [@media(hover:none)]: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
// On touch devices (no hover capability) keep the action row
// always visible so users can tap Send / archive / delete
// without a hover gesture they don't have. Mouse-driven
// desktops keep the existing hover-reveal behaviour.
className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 [@media(hover:none)]: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={(e) => {
e.stopPropagation();
setConfirmDeleteOpen(true);
}}
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-destructive"
aria-label={t("notes.delete", "Delete")}
data-testid={`note-card-delete-${note.id}`}
>
<Trash2 size={15} />
</button>
</div>
</div>
{/* AlertDialog rendered as a sibling (not a child of the clickable card)
so React's portal event bubbling can't trigger the card's onEdit
click handler when the user interacts with the confirmation. */}
<AlertDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
>
<AlertDialogContent data-testid={`note-delete-dialog-${note.id}`}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteConfirm", "Delete this note?")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
del.mutate(note.id);
setConfirmDeleteOpen(false);
}}
data-testid={`note-delete-confirm-${note.id}`}
>
{t("notes.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// File-manager style folder browser shown at the top of the "All notes" view.
function useRelativeTime(iso: string) {
const { i18n } = useTranslation();
return useMemo(() => {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return formatDistanceToNow(d, {
addSuffix: true,
locale: i18n.language === "ar" ? dfAr : dfEn,
});
} catch {
return "";
}
}, [iso, i18n.language]);
}
function FoldersBrowser({
folders,
viewMode,
onSelect,
onDropNote,
}: {
folders: NoteFolder[];
viewMode: ViewMode;
onSelect: (s: FolderSelection) => void;
onDropNote: (target: "unfiled" | number) => void;
}) {
const { t } = useTranslation();
const del = useDeleteFolder();
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
// Drop deselected ids if folders disappear (e.g. after bulk delete).
useEffect(() => {
setSelectedIds((prev) => {
const valid = new Set(folders.map((f) => f.id));
const next = new Set<number>();
let changed = false;
prev.forEach((id) => {
if (valid.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [folders]);
const total = folders.length;
if (total === 0) return null;
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelection = () => setSelectedIds(new Set());
const itemsLabel =
total === 1
? t("notes.folders.itemCountOne", "1 folder")
: t("notes.folders.itemCount", "{{count}} folders", { count: total });
const selectedCount = selectedIds.size;
const runBulkDelete = async () => {
const ids = Array.from(selectedIds);
setBulkConfirmOpen(false);
try {
await Promise.all(ids.map((id) => del.mutateAsync(id)));
clearSelection();
} catch {
toast({
title: t("notes.folders.bulkDeleteFailed", "Some folders could not be deleted"),
variant: "destructive",
});
}
};
return (
<div className="mt-2 mb-4 space-y-3" data-testid="notes-folders-browser">
{selectedCount > 0 && (
<div
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-slate-900 text-white shadow"
data-testid="folders-bulk-bar"
>
<span className="text-sm" data-testid="folders-bulk-count">
{t("notes.folders.bulkSelected", "{{count}} selected", {
count: selectedCount,
})}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={clearSelection}
className="text-xs px-2 py-1 rounded-md hover:bg-white/10"
data-testid="folders-bulk-clear"
>
{t("common.cancel", "Cancel")}
</button>
<button
type="button"
onClick={() => setBulkConfirmOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700"
data-testid="folders-bulk-delete"
>
<Trash2 size={14} />
{t("notes.folders.deleteAction", "Delete")}
</button>
</div>
</div>
)}
{viewMode === "grid" ? (
<div
className="grid gap-3"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))" }}
data-testid="notes-folders-grid"
>
{folders.map((f) => (
<FolderItem
key={f.id}
folder={f}
viewMode="grid"
isSelected={selectedIds.has(f.id)}
onToggleSelect={() => toggleSelect(f.id)}
onSelect={() => onSelect({ kind: "folder", id: f.id })}
onDropNote={() => onDropNote(f.id)}
/>
))}
</div>
) : (
<div
className="rounded-lg border border-slate-200/70 bg-white/60 divide-y divide-slate-200/60 overflow-hidden"
data-testid="notes-folders-list"
>
{folders.map((f) => (
<FolderItem
key={f.id}
folder={f}
viewMode="list"
isSelected={selectedIds.has(f.id)}
onToggleSelect={() => toggleSelect(f.id)}
onSelect={() => onSelect({ kind: "folder", id: f.id })}
onDropNote={() => onDropNote(f.id)}
/>
))}
</div>
)}
<div
className="text-xs text-muted-foreground pt-1"
data-testid="notes-folders-count"
>
{itemsLabel}
</div>
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialogContent data-testid="folders-bulk-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.folders.bulkDeleteTitle", "Delete selected folders?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.folders.bulkDeleteConfirm",
"Notes inside these folders will move to Unfiled.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="folders-bulk-delete-cancel">
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
data-testid="folders-bulk-delete-confirm"
onClick={runBulkDelete}
>
{t("notes.folders.deleteAction", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function FolderItem({
folder,
viewMode,
isSelected,
onToggleSelect,
onSelect,
onDropNote,
}: {
folder: NoteFolder;
viewMode: ViewMode;
isSelected: boolean;
onToggleSelect: () => void;
onSelect: () => void;
onDropNote: () => void;
}) {
const { t } = useTranslation();
const update = useUpdateFolder();
const del = useDeleteFolder();
const [editing, setEditing] = useState(false);
const [editingName, setEditingName] = useState(folder.name);
const [confirmOpen, setConfirmOpen] = useState(false);
const [hover, setHover] = useState(false);
const relTime = useRelativeTime(folder.updatedAt || folder.createdAt);
const submitRename = () => {
const name = editingName.trim();
if (!name) return;
update.mutate(
{ id: folder.id, name },
{
onSuccess: () => setEditing(false),
onError: () =>
toast({
title: t("notes.folders.renameFailed", "Could not rename folder"),
variant: "destructive",
}),
},
);
};
const dropHandlers = {
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!hover) setHover(true);
},
onDragLeave: () => setHover(false),
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setHover(false);
onDropNote();
},
};
const kebab = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="p-1 rounded-md text-muted-foreground hover:bg-black/5 hover:text-foreground"
aria-label={t("notes.folders.actions", "Folder actions")}
data-testid={`folder-menu-${folder.id}`}
>
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onSelect={() => {
setEditingName(folder.name);
setEditing(true);
}}
data-testid={`folder-rename-${folder.id}`}
>
<Pencil size={14} className="me-2" />
{t("notes.folders.rename", "Rename folder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setConfirmOpen(true)}
className="text-red-600 focus:text-red-600"
data-testid={`folder-delete-${folder.id}`}
>
<Trash2 size={14} className="me-2" />
{t("notes.folders.delete", "Delete folder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const renameRow = (
<div
className="flex items-center gap-1 w-full"
onClick={(e) => e.stopPropagation()}
>
<Input
autoFocus
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitRename();
if (e.key === "Escape") setEditing(false);
}}
className="h-7 text-sm"
data-testid={`folder-rename-input-${folder.id}`}
/>
<button
type="button"
onClick={submitRename}
className="p-1 text-muted-foreground hover:text-foreground"
aria-label={t("common.save", "Save")}
data-testid={`folder-rename-save-${folder.id}`}
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="p-1 text-muted-foreground hover:text-foreground"
aria-label={t("common.cancel", "Cancel")}
>
<X size={14} />
</button>
</div>
);
const deleteDialog = (
<AlertDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
>
<AlertDialogContent
data-testid={`folder-delete-dialog-${folder.id}`}
>
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.folders.deleteTitle", "Delete this folder?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.folders.deleteConfirm",
"Delete this folder? Notes inside will move to Unfiled.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid={`folder-delete-cancel-${folder.id}`}
>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
data-testid={`folder-delete-confirm-${folder.id}`}
onClick={() => {
del.mutate(folder.id, {
onError: () =>
toast({
title: t(
"notes.folders.deleteFailed",
"Could not delete folder",
),
variant: "destructive",
}),
});
setConfirmOpen(false);
}}
>
{t("notes.folders.deleteAction", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const checkbox = (
<span
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect()}
aria-label={t("notes.folders.select", "Select folder")}
data-testid={`folder-select-checkbox-${folder.id}`}
/>
</span>
);
if (viewMode === "list") {
return (
<>
<div
{...dropHandlers}
data-testid={`folder-drop-${folder.id}`}
data-folder-drop={String(folder.id)}
data-drop-active={hover ? "true" : "false"}
data-selected={isSelected ? "true" : "false"}
className="group flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-slate-50/80 transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-inset data-[selected=true]:bg-slate-100"
onClick={editing ? undefined : onSelect}
>
{checkbox}
<Folder
size={20}
className="shrink-0 text-foreground"
fill="currentColor"
/>
{editing ? (
renameRow
) : (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
data-testid={`folder-select-${folder.id}`}
className="flex-1 flex items-center gap-2 min-w-0 text-start"
>
<span className="text-sm font-medium text-foreground truncate">
{folder.name}
</span>
<span
data-testid={`folder-drop-${folder.id}-count`}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200/80 text-muted-foreground shrink-0"
>
{folder.noteCount}
</span>
</button>
<span className="text-xs text-muted-foreground shrink-0 hidden sm:inline">
{relTime}
</span>
{kebab}
</>
)}
</div>
{deleteDialog}
</>
);
}
return (
<>
<div
{...dropHandlers}
data-testid={`folder-drop-${folder.id}`}
data-folder-drop={String(folder.id)}
data-drop-active={hover ? "true" : "false"}
data-selected={isSelected ? "true" : "false"}
onClick={editing ? undefined : onSelect}
className="group relative rounded-xl border border-slate-200/70 bg-white/70 hover:shadow-md hover:border-slate-300 p-3 cursor-pointer transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[selected=true]:ring-2 data-[selected=true]:ring-slate-900 data-[selected=true]:bg-slate-50"
>
<div className="absolute top-1.5 start-1.5 z-10">
{checkbox}
</div>
<div className="absolute top-1.5 end-1.5 z-10">
{!editing && kebab}
</div>
<div className="flex flex-col items-center text-center pt-3">
<Folder
size={56}
className="text-foreground"
fill="currentColor"
strokeWidth={1.5}
/>
{editing ? (
<div className="mt-2 w-full">{renameRow}</div>
) : (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
data-testid={`folder-select-${folder.id}`}
className="mt-2 text-sm font-medium text-foreground line-clamp-2 break-words w-full"
>
{folder.name}
</button>
{relTime && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{relTime}
</div>
)}
<span
data-testid={`folder-drop-${folder.id}-count`}
className="mt-1 text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200/80 text-muted-foreground"
>
{folder.noteCount}
</span>
</>
)}
</div>
</div>
{deleteDialog}
</>
);
}
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.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`received-note-checklist-${n.id}`}
className="mt-1"
/>
) : (
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.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`sent-note-checklist-${n.id}`}
className="mt-1"
/>
) : (
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 [confirmOpen, setConfirmOpen] = useState(false);
const requestSubmit = () => {
if (picked.size === 0) return;
setConfirmOpen(true);
};
const confirmSubmit = () => {
send.mutate(
{ id: noteId, recipientUserIds: Array.from(picked) },
{
onSuccess: () => {
toast({ title: t("notes.sendSuccess", "Note sent") });
setConfirmOpen(false);
onClose();
},
onError: () => {
setConfirmOpen(false);
toast({
title: t("notes.sendFailed", "Could not send note"),
variant: "destructive",
});
},
},
);
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="send-note-dialog">
<DialogHeader className="pr-8">
<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 start-2.5"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
className="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={requestSubmit}
disabled={picked.size === 0 || send.isPending}
data-testid="send-note-submit"
>
<Send size={14} className="me-1" />
{t("notes.send", "Send")}
</Button>
</div>
</DialogContent>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent data-testid="send-note-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.confirmSendTitle", "Send this note?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("notes.confirmSendBody", { count: picked.size })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmSubmit}
disabled={send.isPending}
data-testid="send-note-confirm-submit"
>
{t("notes.confirmSendCta", "Send")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
function ArchivedView({
loadingMine,
mine,
labels,
onEdit,
onSend,
loadingInbox,
archivedReceived,
onOpenThread,
}: {
loadingMine: boolean;
mine: Note[];
labels: NoteLabel[];
onEdit: (n: Note) => void;
onSend: (n: Note) => void;
loadingInbox: boolean;
archivedReceived: ReceivedNote[];
onOpenThread: (id: number) => void;
}) {
const { t } = useTranslation();
if (loadingMine || loadingInbox) return <Loading />;
if (mine.length === 0 && archivedReceived.length === 0) {
return (
<Empty
icon={<StickyNote size={48} />}
text={t("notes.emptyArchived", "No archived notes")}
/>
);
}
return (
<div className="space-y-8 mt-2">
{mine.length > 0 && (
<div>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{t("notes.archivedSection.myNotes", "My archived notes")}
</h2>
<Section title="" notes={mine} labels={labels} onEdit={onEdit} onSend={onSend} />
</div>
)}
{archivedReceived.length > 0 && (
<div data-testid="notes-archived-inbox">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{t("notes.archivedSection.inbox", "Archived inbox")}
</h2>
<ReceivedList
loading={false}
notes={archivedReceived}
onOpen={onOpenThread}
/>
</div>
)}
</div>
);
}
function ThreadDialog({
noteId,
onClose,
autoFocusReply = false,
}: {
noteId: number;
onClose: () => void;
autoFocusReply?: boolean;
}) {
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 [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
const markedRef = useRef(false);
const replyInputRef = useRef<HTMLTextAreaElement | null>(null);
const focusedRef = useRef(false);
// When opened from the incoming-note popup with reply intent, jump
// focus into the reply input as soon as the thread loads.
useEffect(() => {
if (!autoFocusReply || focusedRef.current) return;
if (!thread) return;
const el = replyInputRef.current;
if (!el) return;
focusedRef.current = true;
el.focus();
}, [autoFocusReply, thread]);
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;
if (!thread) return;
const isOwner = thread.isOwner;
const recipients = thread.recipients;
let recipientUserId: number | undefined;
if (isOwner) {
if (recipients.length === 0) return;
if (recipients.length === 1) {
recipientUserId = recipients[0].recipientUserId;
} else {
if (ownerReplyTarget == null) return;
recipientUserId = ownerReplyTarget;
}
}
reply.mutate(
{ id: noteId, content: c, recipientUserId },
{ onSuccess: () => setReplyText("") },
);
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent
className={`max-w-lg max-h-[85vh] flex flex-col ${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 />
) : (
<>
{/* Three vertical zones inside the 85vh-capped DialogContent:
1) static meta + recipient chips (always visible),
2) scrollable replies-only region,
3) static composer (pinned below).
This keeps the From line, recipient pills, and the reply
box on screen while long conversations scroll inside the
middle zone. */}
<div className="shrink-0 flex flex-col gap-2" data-testid="note-thread-meta">
<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.kind === "checklist" ? (
<ChecklistView
items={thread.items ?? []}
testIdPrefix={`thread-checklist-${thread.id}`}
/>
) : (
thread.content && (
<div className="text-sm text-foreground/85 whitespace-pre-wrap">
{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>
<div className="border-t pt-3 flex flex-col flex-1 min-h-0">
<div className="text-xs text-muted-foreground mb-1.5 flex items-center gap-1 shrink-0">
<MessageCircle size={12} />
{t("notes.replies", "Replies")}
{thread.replies.length > 0 && (
<span className="text-foreground/70">
({thread.replies.length})
</span>
)}
</div>
<div
className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1"
data-testid="note-thread-scroll"
>
{thread.replies.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
{t("notes.noRepliesYet", "No replies yet")}
</div>
) : thread.isOwner || thread.isAdmin ? (
<GroupedReplies thread={thread} lang={lang} />
) : (
<div className="space-y-2">
{thread.replies.map((r) => (
<ReplyBubble key={r.id} reply={r} lang={lang} />
))}
</div>
)}
</div>
</div>
{(thread.isOwner ||
(thread.myStatus !== null && thread.myStatus !== "archived")) && (
<div className="border-t pt-3 flex flex-col gap-2 shrink-0">
{thread.isOwner && thread.recipients.length > 1 && (
<select
value={ownerReplyTarget ?? ""}
onChange={(e) =>
setOwnerReplyTarget(
e.target.value === "" ? null : Number(e.target.value),
)
}
data-testid="thread-owner-reply-target"
className="text-sm rounded-md border border-black/10 bg-white/80 px-2 py-1.5"
>
<option value="">
{t("notes.to", "To:")}
</option>
{thread.recipients.map((r) => (
<option key={r.recipientUserId} value={r.recipientUserId}>
{userDisplayName(r.recipient, lang)}
</option>
))}
</select>
)}
<Textarea
ref={replyInputRef}
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">
{!thread.isOwner ? (
<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>
) : (
<span />
)}
<Button
size="sm"
onClick={submitReply}
disabled={
!replyText.trim() ||
reply.isPending ||
(thread.isOwner &&
thread.recipients.length > 1 &&
ownerReplyTarget == null)
}
data-testid="thread-reply-submit"
>
<Send size={14} className="me-1" />
{t("notes.sendReply", "Send reply")}
</Button>
</div>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
);
}
function ReplyBubble({
reply,
lang,
}: {
reply: NoteThread["replies"][number];
lang: string;
}) {
return (
<div
data-testid={`thread-reply-${reply.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 flex items-center justify-between gap-2">
<span className="truncate">{userDisplayName(reply.sender, lang)}</span>
<span className="shrink-0">{formatReplyTime(reply.createdAt, lang)}</span>
</div>
<div className="whitespace-pre-wrap break-words">{reply.content}</div>
</div>
);
}
function formatReplyTime(iso: string, lang: string) {
try {
return new Date(iso).toLocaleString(lang === "ar" ? "ar" : "en", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function GroupedReplies({
thread,
lang,
}: {
thread: NoteThread;
lang: string;
}) {
const { t } = useTranslation();
const recipientNames = new Map<number, string>();
for (const r of thread.recipients) {
recipientNames.set(r.recipientUserId, userDisplayName(r.recipient, lang));
}
// Group replies by the recipient that the reply is "with" — i.e. the
// non-owner participant. For replies the owner sent, that is the
// `recipientUserId` field on the reply; for replies sent by a recipient
// it is the reply's `senderUserId`.
const groups = new Map<number, NoteThread["replies"]>();
const ownerId = thread.senderUserId;
for (const r of thread.replies) {
const otherId = r.senderUserId === ownerId ? r.recipientUserId : r.senderUserId;
if (!groups.has(otherId)) groups.set(otherId, []);
groups.get(otherId)!.push(r);
}
if (groups.size === 0) {
return (
<div className="text-xs text-muted-foreground italic">
{t("notes.noConversationsYet", "No conversations yet")}
</div>
);
}
return (
<div className="space-y-3">
{Array.from(groups.entries()).map(([otherId, replies]) => (
<div
key={otherId}
data-testid={`thread-conversation-${otherId}`}
className="rounded-lg bg-white/40 border border-black/5 p-2"
>
<div className="text-[11px] font-semibold text-foreground/80 mb-1.5">
{t("notes.conversationWith", {
name: recipientNames.get(otherId) ?? `#${otherId}`,
})}
</div>
<div className="space-y-2">
{replies.map((r) => (
<ReplyBubble key={r.id} reply={r} lang={lang} />
))}
</div>
</div>
))}
</div>
);
}
function newItemId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
}
function ChecklistEditor({
items,
onChange,
testIdPrefix,
}: {
items: ChecklistItem[];
onChange: (next: ChecklistItem[]) => void;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
const focusItemIdRef = useRef<string | null>(null);
// After append (Enter or Add), shift focus to the freshly inserted row.
useEffect(() => {
const id = focusItemIdRef.current;
if (!id) return;
const el = inputRefs.current.get(id);
if (el) {
el.focus();
focusItemIdRef.current = null;
}
}, [items]);
const setItem = (id: string, patch: Partial<ChecklistItem>) => {
onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it)));
};
const removeItem = (id: string) => {
onChange(items.filter((it) => it.id !== id));
};
const appendItem = () => {
const item: ChecklistItem = { id: newItemId(), text: "", done: false };
focusItemIdRef.current = item.id;
onChange([...items, item]);
};
return (
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
{items.length === 0 && (
<div className="text-xs text-muted-foreground italic px-1">
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
</div>
)}
{items.map((it) => (
<div
key={it.id}
className="flex items-center gap-2"
data-testid={`${testIdPrefix}-item-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
onChange={(e) => setItem(it.id, { done: e.target.checked })}
className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500"
aria-label={t("notes.checklist.itemPlaceholder", "List item")}
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<Input
ref={(el) => {
if (el) inputRefs.current.set(it.id, el);
else inputRefs.current.delete(it.id);
}}
value={it.text}
onChange={(e) => setItem(it.id, { text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
appendItem();
} else if (
e.key === "Backspace" &&
it.text.length === 0 &&
items.length > 1
) {
e.preventDefault();
removeItem(it.id);
}
}}
placeholder={t("notes.checklist.itemPlaceholder", "List item")}
className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${
it.done ? "line-through text-muted-foreground" : ""
}`}
data-testid={`${testIdPrefix}-text-${it.id}`}
/>
<button
type="button"
onClick={() => removeItem(it.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive"
aria-label={t("notes.checklist.removeItem", "Remove item")}
data-testid={`${testIdPrefix}-remove-${it.id}`}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={appendItem}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
data-testid={`${testIdPrefix}-add`}
>
<Plus size={12} />
{t("notes.checklist.addItem", "Add item")}
</button>
</div>
);
}
function ChecklistView({
items,
onToggle,
testIdPrefix,
className = "",
}: {
items: ChecklistItem[];
onToggle?: (id: string, done: boolean) => void;
testIdPrefix: string;
className?: string;
}) {
if (items.length === 0) return null;
return (
<div
className={`space-y-1 ${className}`}
data-testid={`${testIdPrefix}-view`}
onClick={(e) => {
// Prevent the parent card's click handler (which opens edit) when
// toggling a checkbox is the intended action.
if (onToggle) e.stopPropagation();
}}
>
{items.map((it) => (
<label
key={it.id}
className="flex items-start gap-2 text-sm cursor-pointer"
data-testid={`${testIdPrefix}-row-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
disabled={!onToggle}
onChange={(e) => onToggle?.(it.id, e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<span
className={`flex-1 break-words ${
it.done ? "line-through text-muted-foreground" : ""
}`}
>
{it.text || "\u00A0"}
</span>
</label>
))}
</div>
);
}
function KindToggle({
kind,
onChange,
testId,
}: {
kind: NoteKind;
onChange: (k: NoteKind) => void;
testId?: string;
}) {
const { t } = useTranslation();
const next: NoteKind = kind === "text" ? "checklist" : "text";
return (
<button
type="button"
onClick={() => onChange(next)}
data-testid={testId}
aria-pressed={kind === "checklist"}
aria-label={t("notes.checklist.toggle", "To-do list")}
title={t("notes.checklist.toggle", "To-do list")}
className={`p-1 rounded-md hover:bg-black/5 ${
kind === "checklist"
? "text-amber-600 bg-amber-100/60"
: "text-muted-foreground"
}`}
>
<ListTodo size={15} />
</button>
);
}
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,
folderSelection,
}: {
labels: NoteLabel[];
folderSelection: FolderSelection;
}) {
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 [kind, setKind] = useState<NoteKind>("text");
const [items, setItems] = useState<ChecklistItem[]>([]);
const create = useCreateNote();
// Resolve the folder the new note should land in based on the active
// selection. "all" → null (unfiled / global), "unfiled" → null,
// "folder" → that folder id. Without this the composer always created
// notes in Unfiled, so a note made from inside a folder vanished from
// the current view.
const targetFolderId =
folderSelection.kind === "folder" ? folderSelection.id : null;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (ref.current?.contains(target)) return;
// Ignore clicks inside Radix popover/portal content (the color and
// label menus render in a portal outside the composer's DOM tree).
// Without this guard, picking a swatch would trigger save() on the
// mousedown — before the swatch's onClick updated `color` — so the
// new color was never persisted.
const el = target instanceof Element ? target : null;
if (
el?.closest(
"[data-radix-popper-content-wrapper],[data-radix-portal],[role='dialog']",
)
) {
return;
}
save();
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, title, content, color, labelIds, kind, items]);
const reset = () => {
setTitle("");
setContent("");
setColor("default");
setLabelIds([]);
setKind("text");
setItems([]);
setOpen(false);
};
const save = () => {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
const isEmpty =
!title.trim() &&
(kind === "checklist" ? cleanItems.length === 0 : !content.trim());
if (isEmpty) {
reset();
return;
}
create.mutate(
{
title: title.trim(),
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
folderId: targetFolderId,
},
{ 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"
/>
{kind === "checklist" ? (
<div className="mt-2 px-1">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-composer-checklist"
/>
</div>
) : (
<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} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-composer-kind-toggle"
/>
<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 [kind, setKind] = useState<NoteKind>(note.kind ?? "text");
const [items, setItems] = useState<ChecklistItem[]>(note.items ?? []);
const update = useUpdateNote();
const save = () => {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
update.mutate(
{
id: note.id,
title: title.trim(),
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
},
{ 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"
/>
{kind === "checklist" ? (
<div className="px-1 min-h-[160px]" data-testid="notes-edit-checklist-wrap">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-edit-checklist"
/>
</div>
) : (
<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} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-edit-kind-toggle"
/>
<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 [pendingDeleteLabel, setPendingDeleteLabel] = useState<NoteLabel | null>(
null,
);
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={() => setPendingDeleteLabel(l)}
className="p-1 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
data-testid={`label-delete-${l.id}`}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
))}
</div>
</DialogContent>
<AlertDialog
open={pendingDeleteLabel !== null}
onOpenChange={(o) => {
if (!o) setPendingDeleteLabel(null);
}}
>
<AlertDialogContent
data-testid={
pendingDeleteLabel
? `label-delete-dialog-${pendingDeleteLabel.id}`
: "label-delete-dialog"
}
>
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteLabelConfirm", "Delete this label?")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDeleteLabel) {
del.mutate(pendingDeleteLabel.id);
setPendingDeleteLabel(null);
}
}}
data-testid={
pendingDeleteLabel
? `label-delete-confirm-${pendingDeleteLabel.id}`
: "label-delete-confirm"
}
>
{t("notes.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}