diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 0c6c3df5..0b353187 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1062,6 +1062,16 @@ }, "pinned": "مثبّتة", "others": "أخرى", + "viewGrid": "عرض شبكي", + "viewList": "عرض قائمة", + "select": "تحديد", + "untitled": "بدون عنوان", + "untitledChecklist": "قائمة مهام", + "itemCountOne": "عنصر واحد", + "itemCount": "{{count}} عنصراً", + "selectedCount": "تم تحديد {{count}}", + "deleteSelectedConfirm": "حذف الملاحظات المحددة؟", + "moreActions": "خيارات أخرى", "empty": "ستظهر ملاحظاتك هنا", "emptyArchived": "لا توجد ملاحظات في الأرشيف", "emptyReceived": "لم تصلك أي ملاحظة بعد", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index cb8afec9..723a68db 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 37a38899..3399b14d 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -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("active"); + const [viewMode, setViewModeState] = useState(() => 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(null); const [folderSelection, setFolderSelection] = useState({ @@ -110,6 +139,21 @@ export default function NotesPage() { const [sendForNoteId, setSendForNoteId] = useState(null); const [openThreadId, setOpenThreadId] = useState(null); const [threadReplyIntent, setThreadReplyIntent] = useState(false); + const [selectedIds, setSelectedIds] = useState>(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() { ))} )} + {view === "active" && ( +
+ + +
+ )} @@ -358,24 +437,17 @@ export default function NotesPage() { ) : filteredNotes.length === 0 ? ( } text={t("notes.empty", "Your notes will appear here")} /> ) : ( -
- {pinned.length > 0 && ( -
setSendForNoteId(n.id)} - /> - )} -
0 ? t("notes.others", "Others") : ""} - notes={others} - labels={labels} - onEdit={setEditing} - onSend={(n) => setSendForNoteId(n.id)} - /> -
+ setSendForNoteId(n.id)} + /> )} )} @@ -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 = { + 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; + 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 ( +
+ {title && ( +

+ {title} +

+ )} + {viewMode === "grid" ? ( +
+ {group.map((n) => ( + onToggleSelect(n.id)} + onEdit={onEdit} + onSend={onSend} + /> + ))} +
+ ) : ( +
+ {group.map((n) => ( + onToggleSelect(n.id)} + onEdit={onEdit} + onSend={onSend} + /> + ))} +
+ )} +
+ ); + }; + + return ( +
+ + {renderGroup(t("notes.pinned", "Pinned"), pinned)} + {renderGroup( + pinned.length > 0 ? t("notes.others", "Others") : "", + others, + )} +
+ {itemsLabel} +
+
+ ); +} + +function SelectionBar({ + selectedIds, + onClear, +}: { + selectedIds: Set; + 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 ( +
+ + {t("notes.selectedCount", "{{count}} selected", { count: ids.length })} + +
+ + + +
+ + + + + {t("notes.deleteSelectedConfirm", "Delete the selected notes?")} + + + + {t("common.cancel", "Cancel")} + + {t("notes.delete", "Delete")} + + + + +
+ ); +} + +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 = ( +
e.stopPropagation()} + > + update.mutate({ id: note.id, color: c })} + /> + update.mutate({ id: note.id, labelIds: ids })} + /> + + + +
+ ); + + const checkbox = ( + + ); + + const pinToggle = ( + + ); + + // 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" ? ( + { + 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 ( + <> +
+ {checkbox} +
+ {note.kind === "checklist" ? ( + + ) : ( + + )} +
+
+
+ + {filename} + + {note.isPinned && ( + + )} + {noteLabels.length > 0 && ( + + {noteLabels.slice(0, 2).map((l) => ( + + {l.name} + + ))} + + )} +
+ {checklistPreview && ( +
{checklistPreview}
+ )} +
+ + {relTime} + + +
+ {actions} +
+
+ { + del.mutate(note.id); + setConfirmDeleteOpen(false); + }} + /> + + ); + } + + // Grid view: card with large folder/file icon, filename, relative time. + return ( + <> +
+
+
+ {checkbox} +
+
+
+ {pinToggle} + +
+
+
+ {note.kind === "checklist" ? ( + + ) : ( + + )} +
+
+ {filename} +
+ {relTime && ( +
+ {relTime} +
+ )} +
+ {checklistPreview && ( +
{checklistPreview}
+ )} + {noteLabels.length > 0 && ( +
+ {noteLabels.slice(0, 3).map((l) => ( + + {l.name} + + ))} +
+ )} +
+ {actions} +
+
+ { + 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 ( + + + + + {t("notes.deleteConfirm", "Delete this note?")} + + + + {t("common.cancel", "Cancel")} + + {t("notes.delete", "Delete")} + + + + + ); +} + function ReceivedList({ loading, notes, diff --git a/attached_assets/image_1778082909789.png b/attached_assets/image_1778082909789.png new file mode 100644 index 00000000..c6f07881 Binary files /dev/null and b/attached_assets/image_1778082909789.png differ diff --git a/attached_assets/image_1778139444852.png b/attached_assets/image_1778139444852.png new file mode 100644 index 00000000..4c72eb66 Binary files /dev/null and b/attached_assets/image_1778139444852.png differ diff --git a/attached_assets/image_1778139463711.png b/attached_assets/image_1778139463711.png new file mode 100644 index 00000000..ce14389a Binary files /dev/null and b/attached_assets/image_1778139463711.png differ