ea3092bb2b
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.
2823 lines
90 KiB
TypeScript
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>
|
|
);
|
|
}
|