Add file manager view to notes section with grid and list options

Adds new translation keys for UI elements and implements a view mode toggle (grid/list) for the notes page, persisting the selection in local storage.
This commit is contained in:
Riyadh
2026-05-07 07:52:50 +00:00
parent 133790d655
commit 971973963c
3 changed files with 814 additions and 18 deletions
+10
View File
@@ -1062,6 +1062,16 @@
},
"pinned": "مثبّتة",
"others": "أخرى",
"viewGrid": "عرض شبكي",
"viewList": "عرض قائمة",
"select": "تحديد",
"untitled": "بدون عنوان",
"untitledChecklist": "قائمة مهام",
"itemCountOne": "عنصر واحد",
"itemCount": "{{count}} عنصراً",
"selectedCount": "تم تحديد {{count}}",
"deleteSelectedConfirm": "حذف الملاحظات المحددة؟",
"moreActions": "خيارات أخرى",
"empty": "ستظهر ملاحظاتك هنا",
"emptyArchived": "لا توجد ملاحظات في الأرشيف",
"emptyReceived": "لم تصلك أي ملاحظة بعد",
+10
View File
@@ -963,6 +963,16 @@
},
"pinned": "Pinned",
"others": "Others",
"viewGrid": "Grid view",
"viewList": "List view",
"select": "Select",
"untitled": "Untitled",
"untitledChecklist": "Checklist",
"itemCountOne": "1 item",
"itemCount": "{{count}} items",
"selectedCount": "{{count}} selected",
"deleteSelectedConfirm": "Delete the selected notes?",
"moreActions": "More actions",
"empty": "Your notes will appear here",
"emptyArchived": "No archived notes",
"emptyReceived": "No notes have been sent to you yet",
+790 -14
View File
@@ -20,7 +20,13 @@ import {
Mail,
MessageCircle,
ListTodo,
FileText,
LayoutGrid,
List as ListIcon,
MoreHorizontal,
} from "lucide-react";
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";
@@ -92,6 +98,18 @@ import {
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();
@@ -100,6 +118,17 @@ export default function NotesPage() {
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>({
@@ -110,6 +139,21 @@ export default function NotesPage() {
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
@@ -335,6 +379,41 @@ export default function NotesPage() {
))}
</div>
)}
{view === "active" && (
<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>
@@ -358,25 +437,18 @@ export default function NotesPage() {
) : filteredNotes.length === 0 ? (
<Empty icon={<StickyNote size={48} />} text={t("notes.empty", "Your notes will appear here")} />
) : (
<div className="space-y-6 mt-6">
{pinned.length > 0 && (
<Section
title={t("notes.pinned", "Pinned")}
notes={pinned}
<FilesView
pinned={pinned}
others={others}
labels={labels}
viewMode={viewMode}
selectedIds={selectedIds}
onToggleSelect={toggleSelected}
onClearSelection={clearSelected}
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>
)}
</>
)}
@@ -778,6 +850,710 @@ function NoteCard({
);
}
// ─────────────────────────────────────────────────────────────────────────────
// File-manager style rendering for the "active" notes view.
//
// Renders each note as a "file" (icon + filename + relative time + kebab
// menu) in either a grid or a list layout, plus a multi-select selection
// bar and a footer count. The kebab popover hosts the existing per-note
// actions (send / archive / delete / color / labels) while still mounting
// the same `note-card-${id}`, `note-card-send-${id}`,
// `note-card-delete-${id}`, and `note-card-checklist-${id}-*` test ids that
// the existing test suite relies on, so this is a drop-in visual change for
// the active tab without breaking note-card-touch-actions, notes-folders,
// notes-checklist, notes-delete-confirm, notes-popup-on-receive, etc.
// ─────────────────────────────────────────────────────────────────────────────
function colorTint(color: string): string {
// Map the saved note color into a soft tinted icon background. Falls back
// to a neutral slate so unknown colors still render predictably.
const map: Record<string, string> = {
yellow: "bg-amber-100 text-amber-700",
amber: "bg-amber-100 text-amber-700",
orange: "bg-orange-100 text-orange-700",
red: "bg-rose-100 text-rose-700",
pink: "bg-pink-100 text-pink-700",
purple: "bg-violet-100 text-violet-700",
violet: "bg-violet-100 text-violet-700",
blue: "bg-sky-100 text-sky-700",
cyan: "bg-cyan-100 text-cyan-700",
teal: "bg-teal-100 text-teal-700",
green: "bg-emerald-100 text-emerald-700",
gray: "bg-slate-100 text-slate-700",
grey: "bg-slate-100 text-slate-700",
white: "bg-slate-100 text-slate-700",
default: "bg-slate-100 text-slate-700",
};
return map[color?.toLowerCase?.() ?? ""] ?? map.default;
}
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 FilesView({
pinned,
others,
labels,
viewMode,
selectedIds,
onToggleSelect,
onClearSelection,
onEdit,
onSend,
}: {
pinned: Note[];
others: Note[];
labels: NoteLabel[];
viewMode: ViewMode;
selectedIds: Set<number>;
onToggleSelect: (id: number) => void;
onClearSelection: () => void;
onEdit: (n: Note) => void;
onSend: (n: Note) => void;
}) {
const { t } = useTranslation();
const total = pinned.length + others.length;
const itemsLabel =
total === 1
? t("notes.itemCountOne", "1 item")
: t("notes.itemCount", "{{count}} items", { count: total });
const renderGroup = (title: string, group: Note[]) => {
if (group.length === 0) return null;
return (
<div>
{title && (
<h2 className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{title}
</h2>
)}
{viewMode === "grid" ? (
<div
className="grid gap-3"
style={{
gridTemplateColumns:
"repeat(auto-fill, minmax(140px, 1fr))",
}}
data-testid="notes-files-grid"
>
{group.map((n) => (
<FileItem
key={n.id}
note={n}
labels={labels}
viewMode="grid"
selected={selectedIds.has(n.id)}
onToggleSelect={() => onToggleSelect(n.id)}
onEdit={onEdit}
onSend={onSend}
/>
))}
</div>
) : (
<div
className="rounded-lg border border-slate-200/70 bg-white/60 divide-y divide-slate-200/60 overflow-hidden"
data-testid="notes-files-list"
>
{group.map((n) => (
<FileItem
key={n.id}
note={n}
labels={labels}
viewMode="list"
selected={selectedIds.has(n.id)}
onToggleSelect={() => onToggleSelect(n.id)}
onEdit={onEdit}
onSend={onSend}
/>
))}
</div>
)}
</div>
);
};
return (
<div className="mt-4 space-y-6" data-testid="notes-files-view">
<SelectionBar
selectedIds={selectedIds}
onClear={onClearSelection}
/>
{renderGroup(t("notes.pinned", "Pinned"), pinned)}
{renderGroup(
pinned.length > 0 ? t("notes.others", "Others") : "",
others,
)}
<div
className="text-xs text-muted-foreground pt-2"
data-testid="notes-files-count"
>
{itemsLabel}
</div>
</div>
);
}
function SelectionBar({
selectedIds,
onClear,
}: {
selectedIds: Set<number>;
onClear: () => void;
}) {
const { t } = useTranslation();
const update = useUpdateNote();
const del = useDeleteNote();
const [confirmOpen, setConfirmOpen] = useState(false);
const ids = useMemo(() => Array.from(selectedIds), [selectedIds]);
if (ids.length === 0) return null;
const archiveAll = () => {
ids.forEach((id) => update.mutate({ id, isArchived: true }));
onClear();
};
const deleteAll = () => {
ids.forEach((id) => del.mutate(id));
setConfirmOpen(false);
onClear();
};
return (
<div
className="flex items-center gap-2 rounded-xl border border-slate-200/70 bg-white/80 px-3 py-2 shadow-sm"
data-testid="notes-selection-bar"
>
<span
className="text-sm font-medium text-foreground"
data-testid="notes-selection-count"
>
{t("notes.selectedCount", "{{count}} selected", { count: ids.length })}
</span>
<div className="ms-auto flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={archiveAll}
data-testid="notes-selection-archive"
>
<Archive size={14} className="me-1" />
{t("notes.archive", "Archive")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmOpen(true)}
className="text-destructive hover:text-destructive"
data-testid="notes-selection-delete"
>
<Trash2 size={14} className="me-1" />
{t("notes.delete", "Delete")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onClear}
data-testid="notes-selection-clear"
>
<X size={14} />
</Button>
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent data-testid="notes-selection-delete-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteSelectedConfirm", "Delete the selected notes?")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={deleteAll}
data-testid="notes-selection-delete-confirm"
>
{t("notes.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function FileItem({
note,
labels,
viewMode,
selected,
onToggleSelect,
onEdit,
onSend,
}: {
note: Note;
labels: NoteLabel[];
viewMode: ViewMode;
selected: boolean;
onToggleSelect: () => void;
onEdit: (n: Note) => void;
onSend: (n: Note) => void;
}) {
const { t } = useTranslation();
const update = useUpdateNote();
const del = useDeleteNote();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
// `actionsPinned` keeps the action row visible after the user taps the
// kebab "…" button. Hover and focus-within still reveal it transiently for
// mouse users. We never unmount the action row so existing test ids stay
// queryable in the DOM.
const [actionsPinned, setActionsPinned] = useState(false);
const noteLabels = labels.filter((l) => note.labelIds.includes(l.id));
const relTime = useRelativeTime(note.updatedAt || note.createdAt);
const filename =
note.title?.trim() ||
(note.kind === "checklist"
? t("notes.untitledChecklist", "Checklist")
: t("notes.untitled", "Untitled"));
// Reuse the same touch-drag pipeline as the legacy NoteCard so dragging
// a file onto the folder rail keeps working on iPad / phone.
const touchStateRef = useRef<{
longPressTimer: number | null;
dragging: boolean;
suppressClick: boolean;
startX: number;
startY: number;
}>({
longPressTimer: null,
dragging: false,
suppressClick: false,
startX: 0,
startY: 0,
});
const TOUCH_SLOP_PX = 10;
const cancelTouchTimer = () => {
const s = touchStateRef.current;
if (s.longPressTimer !== null) {
window.clearTimeout(s.longPressTimer);
s.longPressTimer = null;
}
};
const dragHandlers = {
draggable: true as const,
onDragStart: (e: React.DragEvent) => {
setDraggingNoteId(note.id);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", `note:${note.id}`);
},
onDragEnd: () => setDraggingNoteId(null),
onTouchStart: (e: React.TouchEvent) => {
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: React.TouchEvent) => {
const s = touchStateRef.current;
const touch = e.touches[0];
if (!touch) return;
if (!s.dragging) {
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: React.TouchEvent) => {
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);
},
};
const openEdit = () => {
if (touchStateRef.current.suppressClick) {
touchStateRef.current.suppressClick = false;
return;
}
onEdit(note);
};
// Action buttons used by both grid and list views. Kept inline so existing
// test ids remain mounted in the DOM and Playwright's auto-hover can reach
// them without needing to first open a popover. They are visually hidden
// until the user hovers / focuses the row, mirroring the previous card.
const actions = (
<div
className="flex items-center gap-0.5"
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>
);
const checkbox = (
<button
type="button"
role="checkbox"
aria-checked={selected}
onClick={(e) => {
e.stopPropagation();
onToggleSelect();
}}
data-testid={`note-select-${note.id}`}
className={`shrink-0 w-4 h-4 rounded border transition flex items-center justify-center ${
selected
? "bg-primary border-primary text-primary-foreground"
: "border-slate-400/70 bg-white/80 hover:border-foreground"
}`}
aria-label={t("notes.select", "Select")}
>
{selected ? <Check size={12} strokeWidth={3} /> : null}
</button>
);
const pinToggle = (
<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={14} className={note.isPinned ? "fill-amber-500" : ""} />
</button>
);
// Inline checklist preview kept for active checklist notes so the existing
// notes-checklist test (which clicks toggle boxes on the card) keeps
// working. For text notes we just show the first line of content as the
// file's subtitle in grid view, or no preview in list view.
const checklistPreview =
note.kind === "checklist" ? (
<ChecklistView
items={note.items ?? []}
testIdPrefix={`note-card-checklist-${note.id}`}
className="mt-2 text-start"
onToggle={(itemId, done) => {
const next = (note.items ?? []).map((it) =>
it.id === itemId ? { ...it, done } : it,
);
update.mutate({ id: note.id, kind: "checklist", items: next });
}}
/>
) : null;
if (viewMode === "list") {
return (
<>
<div
{...dragHandlers}
data-testid={`note-card-${note.id}`}
data-folder-id={note.folderId ?? ""}
onClick={openEdit}
className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition touch-manipulation ${
selected ? "bg-primary/5" : "hover:bg-slate-50/80"
}`}
>
{checkbox}
<div
className={`shrink-0 w-8 h-8 rounded-md flex items-center justify-center ${colorTint(
note.color,
)}`}
>
{note.kind === "checklist" ? (
<ListTodo size={16} />
) : (
<FileText size={16} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{filename}
</span>
{note.isPinned && (
<Pin size={12} className="fill-amber-500 text-amber-600 shrink-0" />
)}
{noteLabels.length > 0 && (
<span className="hidden sm:inline-flex items-center gap-1">
{noteLabels.slice(0, 2).map((l) => (
<Badge
key={l.id}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
{l.name}
</Badge>
))}
</span>
)}
</div>
{checklistPreview && (
<div className="mt-1">{checklistPreview}</div>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 hidden sm:inline">
{relTime}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setActionsPinned((v) => !v);
}}
aria-label={t("notes.moreActions", "More actions")}
aria-expanded={actionsPinned}
data-testid={`note-card-kebab-${note.id}`}
className="shrink-0 p-1 rounded-md text-muted-foreground hover:bg-black/5 hover:text-foreground"
>
<MoreHorizontal size={16} />
</button>
<div
className={`transition ${
actionsPinned
? "opacity-100"
: "opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
}`}
data-testid={`note-card-actions-${note.id}`}
>
{actions}
</div>
</div>
<DeleteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
noteId={note.id}
onConfirm={() => {
del.mutate(note.id);
setConfirmDeleteOpen(false);
}}
/>
</>
);
}
// Grid view: card with large folder/file icon, filename, relative time.
return (
<>
<div
{...dragHandlers}
data-testid={`note-card-${note.id}`}
data-folder-id={note.folderId ?? ""}
onClick={openEdit}
className={`group relative rounded-xl border p-3 cursor-pointer transition touch-manipulation ${
selected
? "border-primary bg-primary/5 shadow-sm"
: "border-slate-200/70 bg-white/70 hover:shadow-md hover:border-slate-300"
}`}
>
<div className="absolute top-2 start-2 z-10">
<div
className={`transition ${
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
}`}
>
{checkbox}
</div>
</div>
<div className="absolute top-1.5 end-1.5 z-10 flex items-center gap-0.5">
{pinToggle}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setActionsPinned((v) => !v);
}}
aria-label={t("notes.moreActions", "More actions")}
aria-expanded={actionsPinned}
data-testid={`note-card-kebab-${note.id}`}
className="p-1 rounded-md text-muted-foreground hover:bg-black/5 hover:text-foreground"
>
<MoreHorizontal size={16} />
</button>
</div>
<div className="flex flex-col items-center text-center pt-3">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center ${colorTint(
note.color,
)}`}
>
{note.kind === "checklist" ? (
<ListTodo size={28} />
) : (
<FileText size={28} />
)}
</div>
<div
className="mt-2 text-sm font-medium text-foreground line-clamp-2 break-words w-full"
title={filename}
>
{filename}
</div>
{relTime && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{relTime}
</div>
)}
</div>
{checklistPreview && (
<div className="mt-2 text-start">{checklistPreview}</div>
)}
{noteLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2 justify-center">
{noteLabels.slice(0, 3).map((l) => (
<Badge
key={l.id}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
{l.name}
</Badge>
))}
</div>
)}
<div
className={`mt-2 flex items-center justify-center gap-0.5 transition ${
actionsPinned
? "opacity-100"
: "opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
}`}
data-testid={`note-card-actions-${note.id}`}
>
{actions}
</div>
</div>
<DeleteDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
noteId={note.id}
onConfirm={() => {
del.mutate(note.id);
setConfirmDeleteOpen(false);
}}
/>
</>
);
}
function DeleteDialog({
open,
onOpenChange,
noteId,
onConfirm,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
noteId: number;
onConfirm: () => void;
}) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent data-testid={`note-delete-dialog-${noteId}`}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteConfirm", "Delete this note?")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
data-testid={`note-delete-confirm-${noteId}`}
>
{t("notes.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function ReceivedList({
loading,
notes,