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:
@@ -1062,6 +1062,16 @@
|
||||
},
|
||||
"pinned": "مثبّتة",
|
||||
"others": "أخرى",
|
||||
"viewGrid": "عرض شبكي",
|
||||
"viewList": "عرض قائمة",
|
||||
"select": "تحديد",
|
||||
"untitled": "بدون عنوان",
|
||||
"untitledChecklist": "قائمة مهام",
|
||||
"itemCountOne": "عنصر واحد",
|
||||
"itemCount": "{{count}} عنصراً",
|
||||
"selectedCount": "تم تحديد {{count}}",
|
||||
"deleteSelectedConfirm": "حذف الملاحظات المحددة؟",
|
||||
"moreActions": "خيارات أخرى",
|
||||
"empty": "ستظهر ملاحظاتك هنا",
|
||||
"emptyArchived": "لا توجد ملاحظات في الأرشيف",
|
||||
"emptyReceived": "لم تصلك أي ملاحظة بعد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user