From 7fbb999d92f04478529abe4ab6088c052a9d24c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:48:08 +0000 Subject: [PATCH] Folder tree filter, right-click add menus, and reworked card layout - Filter box (with clear "x" and Escape) on both folder trees: the left panel and the folder picker modal. Keeps matches plus their ancestors; "All notes" always shows. Branches auto-open while filtering without touching the saved expanded state. - Right-click on tree folders, "All notes", and folder-bar chips opens the folder menu at the cursor (create items for that folder). Right-click on empty notes background opens a create-only menu for the current folder. Note cards keep the browser's own context menu. - Extract the folder menu into a shared FolderMenu (viewport-clamped, Escape/outside-click to close) used by the tree, folder bar and background. - Card view: add Created date on its own line under the category, move the title to the top under it (2 lines before ellipsis), preview directly below, tags pinned to the bottom. - Unit tests for the filter/prune/menu-target helpers. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016UqmgUYdnBJ6ahmmJmWcNc --- frontend/src/components/FolderFilterInput.tsx | 46 ++++ frontend/src/components/FolderIconBar.tsx | 83 ++------ frontend/src/components/FolderMenu.tsx | 136 ++++++++++++ frontend/src/components/FolderPickerModal.tsx | 18 +- frontend/src/components/FolderTreePanel.tsx | 198 +++++++----------- frontend/src/components/NoteCard.tsx | 130 ++++++------ frontend/src/utils/folderTree.test.ts | 83 ++++++++ frontend/src/utils/folderTree.ts | 43 ++++ frontend/src/views/ListView.tsx | 57 +++-- 9 files changed, 528 insertions(+), 266 deletions(-) create mode 100644 frontend/src/components/FolderFilterInput.tsx create mode 100644 frontend/src/components/FolderMenu.tsx create mode 100644 frontend/src/utils/folderTree.test.ts diff --git a/frontend/src/components/FolderFilterInput.tsx b/frontend/src/components/FolderFilterInput.tsx new file mode 100644 index 0000000..a736359 --- /dev/null +++ b/frontend/src/components/FolderFilterInput.tsx @@ -0,0 +1,46 @@ +import { Search, X } from 'lucide-react' + +interface Props { + value: string + onChange: (value: string) => void + placeholder?: string + autoFocus?: boolean + className?: string +} + +/** Name filter for a folder tree: a search field with an "x" at the end of the line that + * clears it (Escape does too). */ +export default function FolderFilterInput({ + value, + onChange, + placeholder = 'Filter folders…', + autoFocus = false, + className = '', +}: Props) { + return ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape' && value) { e.stopPropagation(); onChange('') } }} + placeholder={placeholder} + aria-label="Filter folders" + autoFocus={autoFocus} + className="w-full pl-7 pr-7 py-1 text-sm rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-800 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> + {value && ( + + )} +
+ ) +} diff --git a/frontend/src/components/FolderIconBar.tsx b/frontend/src/components/FolderIconBar.tsx index 337bab7..1b32f45 100644 --- a/frontend/src/components/FolderIconBar.tsx +++ b/frontend/src/components/FolderIconBar.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useEffect } from 'react' -import { createPortal } from 'react-dom' -import { MoreVertical, FolderInput, Palette, Trash2 } from 'lucide-react' +import { useRef, useEffect } from 'react' +import { MoreVertical } from 'lucide-react' import { useDraggable, useDroppable } from '@dnd-kit/core' import type { Folder } from '@/api/folders' import { isDynamicFolder } from '@/utils/folderTree' @@ -10,24 +9,18 @@ interface Props { folders: Folder[] onOpen: (id: string) => void onOpenDynamic: (folder: Folder) => void - onMove: (folder: Folder) => void - onCustomize: (folder: Folder) => void - onDelete: (folder: Folder) => void + /** Show the folder menu at a viewport position — from the ⋮ button or a right-click. */ + onOpenMenu: (folder: Folder, x: number, y: number) => void } interface ChipProps { folder: Folder onOpen: (id: string) => void onOpenDynamic: (folder: Folder) => void - onMove: (folder: Folder) => void - onCustomize: (folder: Folder) => void - onDelete: (folder: Folder) => void + onOpenMenu: (folder: Folder, x: number, y: number) => void } -function FolderChip({ folder, onOpen, onOpenDynamic, onMove, onCustomize, onDelete }: ChipProps) { - const [menuOpen, setMenuOpen] = useState(false) - const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null) - const menuRef = useRef(null) +function FolderChip({ folder, onOpen, onOpenDynamic, onOpenMenu }: ChipProps) { const btnRef = useRef(null) const isDynamic = isDynamicFolder(folder) @@ -38,26 +31,8 @@ function FolderChip({ folder, onOpen, onOpenDynamic, onMove, onCustomize, onDele data: { type: 'folder', folderId: folder.id }, }) - useEffect(() => { - if (!menuOpen) return - const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [menuOpen]) - const setRefs = (el: HTMLDivElement | null) => { setDropRef(el); setDragRef(el) } - function openMenu(e: React.MouseEvent) { - e.stopPropagation() - if (!menuOpen && btnRef.current) { - const rect = btnRef.current.getBoundingClientRect() - setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }) - } - setMenuOpen((o) => !o) - } - return (
(isDynamic ? onOpenDynamic(folder) : onOpen(folder.id))} + // Stop here so the notes background's own right-click menu doesn't also open. + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onOpenMenu(folder, e.clientX, e.clientY) }} title={isDynamic ? folder.search_query ?? undefined : undefined} > - {menuOpen && menuPos && createPortal( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - - - -
, - document.body - )}
) } -export default function FolderIconBar({ folders, onOpen, onOpenDynamic, onMove, onCustomize, onDelete }: Props) { +export default function FolderIconBar({ folders, onOpen, onOpenDynamic, onOpenMenu }: Props) { const scrollRef = useRef(null) useEffect(() => { @@ -139,9 +92,7 @@ export default function FolderIconBar({ folders, onOpen, onOpenDynamic, onMove, folder={folder} onOpen={onOpen} onOpenDynamic={onOpenDynamic} - onMove={onMove} - onCustomize={onCustomize} - onDelete={onDelete} + onOpenMenu={onOpenMenu} /> ))} diff --git a/frontend/src/components/FolderMenu.tsx b/frontend/src/components/FolderMenu.tsx new file mode 100644 index 0000000..edd07d9 --- /dev/null +++ b/frontend/src/components/FolderMenu.tsx @@ -0,0 +1,136 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { + FolderPlus, Plus, Upload, Globe, Search, FolderInput, Palette, Trash2, type LucideIcon, +} from 'lucide-react' +import type { Folder } from '@/api/folders' +import type { FolderMenuTarget } from '@/utils/folderTree' + +/** Everything a folder menu can do. The folder tree, the folder bar and the notes + * background all show this one menu, so they share one set of handlers. */ +export interface FolderMenuActions { + onNewSubfolder: (parentId: string | null) => void + onNewDynamicFolder: (parentId: string | null) => void + onNewNote: (folderId: string | null) => void + onImport: (folderId: string | null) => void + onImportUrl: (folderId: string | null) => void + onMove: (folder: Folder) => void + onCustomize: (folder: Folder) => void + onDelete: (folder: Folder) => void // parent decides archive vs. permanent delete + onEmptyArchive: () => void +} + +interface Props { + target: FolderMenuTarget + /** Viewport position of the menu's top-left corner; nudged to stay on screen. */ + x: number + y: number + actions: FolderMenuActions + onClose: () => void +} + +const EDGE = 8 + +export default function FolderMenu({ target, x, y, actions, onClose }: Props) { + const ref = useRef(null) + const [pos, setPos] = useState({ left: x, top: y }) + + // Measure once rendered and pull the menu back inside the viewport (runs before paint, + // so it never flashes in the wrong place). + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const { width, height } = el.getBoundingClientRect() + setPos({ + left: Math.max(EDGE, Math.min(x, window.innerWidth - width - EDGE)), + top: Math.max(EDGE, Math.min(y, window.innerHeight - height - EDGE)), + }) + }, [x, y, target]) + + // Close on any outside press or Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [onClose]) + + function item(key: string, Icon: LucideIcon, label: string, onClick: () => void, danger = false) { + return ( + + ) + } + + function createItems(folderId: string | null) { + return [ + item('new-sub', FolderPlus, folderId ? 'New subfolder' : 'New folder', () => actions.onNewSubfolder(folderId)), + item('new-dynamic', Search, 'New dynamic folder', () => actions.onNewDynamicFolder(folderId)), + item('new-note', Plus, 'New note', () => actions.onNewNote(folderId)), + item('import', Upload, 'Import Markdown', () => actions.onImport(folderId)), + item('import-url', Globe, 'Import URL', () => actions.onImportUrl(folderId)), + ] + } + + function items() { + switch (target.kind) { + case 'bin': + return [item('empty', Trash2, 'Empty Archive Bin', actions.onEmptyArchive, true)] + case 'archived': { + const { folder } = target + return [ + item('restore', FolderInput, 'Move out / restore…', () => actions.onMove(folder)), + item('delete', Trash2, 'Delete permanently', () => actions.onDelete(folder), true), + ] + } + // Dynamic (saved-search) folder: a leaf, so no "new child" actions. + case 'dynamic': { + const { folder } = target + return [ + item('move', FolderInput, 'Move to…', () => actions.onMove(folder)), + item('customize', Palette, 'Customize', () => actions.onCustomize(folder)), + item('delete', Trash2, 'Delete', () => actions.onDelete(folder), true), + ] + } + case 'create': + return createItems(target.folderId) + case 'normal': { + const { folder } = target + return [ + ...createItems(folder.id), +
, + item('move', FolderInput, 'Move to…', () => actions.onMove(folder)), + item('customize', Palette, 'Customize', () => actions.onCustomize(folder)), + item('delete', Trash2, 'Delete', () => actions.onDelete(folder), true), + ] + } + } + } + + return createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation() }} + > + {items()} +
, + document.body, + ) +} diff --git a/frontend/src/components/FolderPickerModal.tsx b/frontend/src/components/FolderPickerModal.tsx index e08a85a..f479ce4 100644 --- a/frontend/src/components/FolderPickerModal.tsx +++ b/frontend/src/components/FolderPickerModal.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react' import { Home } from 'lucide-react' import { foldersApi, type Folder } from '@/api/folders' import { resolveFolderIcon } from '@/utils/folderIcons' -import { buildTree, isDynamicFolder } from '@/utils/folderTree' +import { buildTree, isDynamicFolder, folderFilterIds } from '@/utils/folderTree' +import FolderFilterInput from './FolderFilterInput' interface Props { title?: string @@ -16,6 +17,7 @@ interface Props { export default function FolderPickerModal({ title = 'Move to folder', disabledIds, onSelect, onClose }: Props) { const [folders, setFolders] = useState([]) const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState('') useEffect(() => { let active = true @@ -27,12 +29,20 @@ export default function FolderPickerModal({ title = 'Move to folder', disabledId // Dynamic (saved-search) folders are leaves that hold nothing, so they can't be a // move destination. - const tree = useMemo(() => buildTree(folders.filter((f) => !isDynamicFolder(f))), [folders]) + const destinations = useMemo(() => folders.filter((f) => !isDynamicFolder(f)), [folders]) + // A name filter keeps matching folders plus their ancestors; "All notes (root)" below is + // outside the tree, so it always shows. + const filterIds = useMemo(() => folderFilterIds(destinations, filter), [destinations, filter]) + const tree = useMemo( + () => buildTree(filterIds ? destinations.filter((f) => filterIds.has(f.id)) : destinations), + [destinations, filterIds], + ) return (
e.stopPropagation()}>

{title}

+
{hasChildren && isOpen && node.children.map((c) => ( - + ))} ) @@ -214,7 +220,7 @@ export default function FolderTreePanel({ return new Set() }) const [menu, setMenu] = useState(null) - const menuRef = useRef(null) + const [filter, setFilter] = useState('') const isMobileRef = useRef(isMobile) const panelWidthRef = useRef(panelWidth) @@ -224,7 +230,15 @@ export default function FolderTreePanel({ useEffect(() => { panelHeightRef.current = panelHeight }, [panelHeight]) const byId = useMemo(() => indexById(folders), [folders]) - const forest = useMemo(() => buildForest(folders), [folders]) + const fullForest = useMemo(() => buildForest(folders), [folders]) + // While filtering, only matching folders and their ancestors survive; "All notes" (the + // root row) is rendered outside the forest, so it always stays. + const filterIds = useMemo(() => folderFilterIds(folders, filter), [folders, filter]) + const forest = useMemo( + () => (filterIds ? pruneForest(fullForest, filterIds) : fullForest), + [fullForest, filterIds], + ) + const filtering = filterIds !== null const archiveFolder = useMemo(() => findArchiveFolder(folders), [folders]) const archiveId = archiveFolder?.id ?? null const normalRoots = useMemo(() => forest.filter((n) => n.folder.id !== archiveId), [forest, archiveId]) @@ -257,16 +271,6 @@ export default function FolderTreePanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentFolderId, folders]) - // Close the action menu on any outside click. - useEffect(() => { - if (!menu) return - const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenu(null) - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [menu]) - function toggleExpand(id: string) { setExpanded((prev) => { const next = new Set(prev) @@ -279,13 +283,10 @@ export default function FolderTreePanel({ setExpanded((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) } - function openMenu(target: MenuTarget, btn: HTMLElement) { - const rect = btn.getBoundingClientRect() - // Anchor by the button's left edge (the panel hugs the screen's left), clamped - // so the menu never spills off the right side. - const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENU_WIDTH - 8)) - setMenu({ target, top: rect.bottom + 4, left }) + function openMenu(target: FolderMenuTarget, x: number, y: number) { + setMenu({ target, x, y }) } + const closeMenu = useCallback(() => setMenu(null), []) function startResize(e: React.MouseEvent) { e.preventDefault() @@ -315,7 +316,7 @@ export default function FolderTreePanel({ } const ctx: RowCtx = { - currentFolderId, archiveId, byId, draggingFolderId, expanded, toggleExpand, expand, openMenu, onOpenFolder, onOpenDynamic, + currentFolderId, archiveId, byId, draggingFolderId, expanded, filtering, toggleExpand, expand, openMenu, onOpenFolder, onOpenDynamic, } const rootBtnRef = useRef(null) // "All notes" is the root: dropping here moves a note or folder out to the top level. @@ -325,64 +326,6 @@ export default function FolderTreePanel({ disabled: !canDropFolderOn(draggingFolderId, null, byId), }) - function menuItem(key: string, Icon: LucideIcon, label: string, onClick: () => void, danger = false) { - return ( - - ) - } - - function renderMenuItems(target: MenuTarget) { - const { kind, folder } = target - const fid = folder?.id ?? null - if (kind === 'bin') { - return [menuItem('empty', Trash2, 'Empty Archive Bin', onEmptyArchive, true)] - } - if (kind === 'archived' && folder) { - return [ - menuItem('restore', FolderInput, 'Move out / restore…', () => onMove(folder)), - menuItem('delete', Trash2, 'Delete permanently', () => onDelete(folder), true), - ] - } - // Dynamic (saved-search) folder: a leaf, so no "new child" actions. - if (kind === 'dynamic' && folder) { - return [ - menuItem('move', FolderInput, 'Move to…', () => onMove(folder)), - menuItem('customize', Palette, 'Customize', () => onCustomize(folder)), - menuItem('delete', Trash2, 'Delete', () => onDelete(folder), true), - ] - } - if (kind === 'root') { - return [ - menuItem('new-folder', FolderPlus, 'New folder', () => onNewSubfolder(null)), - menuItem('new-dynamic', Search, 'New dynamic folder', () => onNewDynamicFolder(null)), - menuItem('new-note', Plus, 'New note', () => onNewNote(null)), - menuItem('import', Upload, 'Import Markdown', () => onImport(null)), - menuItem('import-url', Globe, 'Import URL', () => onImportUrl(null)), - ] - } - // normal folder - if (!folder) return null - return [ - menuItem('new-sub', FolderPlus, 'New subfolder', () => onNewSubfolder(fid)), - menuItem('new-dynamic', Search, 'New dynamic folder', () => onNewDynamicFolder(fid)), - menuItem('new-note', Plus, 'New note', () => onNewNote(fid)), - menuItem('import', Upload, 'Import Markdown', () => onImport(fid)), - menuItem('import-url', Globe, 'Import URL', () => onImportUrl(fid)), -
, - menuItem('move', FolderInput, 'Move to…', () => onMove(folder)), - menuItem('customize', Palette, 'Customize', () => onCustomize(folder)), - menuItem('delete', Trash2, 'Delete', () => onDelete(folder), true), - ] - } - if (!open) { return (
@@ -425,6 +368,10 @@ export default function FolderTreePanel({
+
+ +
+ @@ -475,16 +432,17 @@ export default function FolderTreePanel({ title="Drag to resize" /> - {menu && createPortal( -
e.stopPropagation()} - > - {renderMenuItems(menu.target)} -
, - document.body, + {menu && ( + )} ) diff --git a/frontend/src/components/NoteCard.tsx b/frontend/src/components/NoteCard.tsx index d43aa8e..cf92ce9 100644 --- a/frontend/src/components/NoteCard.tsx +++ b/frontend/src/components/NoteCard.tsx @@ -126,7 +126,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa const hasImage = Boolean(note.first_image_url) return (
)} - {/* Top row: category + time + pin */} -
-
- {category - ? - : Uncategorised} + {/* Content column, top to bottom: category + actions, dates, title, preview, and + the tags pinned to the bottom edge. */} +
+
+
+ {category + ? + : Uncategorised} +
+
+ {note.is_shared && ( + + )} + {onPin && ( + + )} + {onToggleSelect && ( + + )} + +
-
- - {relativeDate(note.modified_at)} - - {note.is_shared && ( - - )} - {onPin && ( - - )} - {onToggleSelect && ( - - )} - + + {/* Dates sit on their own line under the header so both fit on a narrow card. */} +
+ Created {relativeDate(note.created_at)} + + Modified {relativeDate(note.modified_at)}
-
- {/* Bottom: title + preview + tags */} -

{note.title || 'Untitled'}

{note.content_preview || 'No content'}

{visibleTags.length > 0 && ( -
+
{visibleTags.map((tag) => ( = {}): Folder { + return { + id, name, parent_folder_id: parent, sort_order: 0, icon_type: null, icon_value: null, + color: null, system_key: null, search_query: null, created_at: '', modified_at: '', ...extra, + } +} + +// Work +// ├─ Projects +// │ └─ Alpha +// └─ Meetings +// Personal +// └─ Recipes +// Archive Bin (system) +// └─ Old Alpha +const folders = [ + folder('work', 'Work'), + folder('projects', 'Projects', 'work'), + folder('alpha', 'Alpha', 'projects'), + folder('meetings', 'Meetings', 'work'), + folder('personal', 'Personal'), + folder('recipes', 'Recipes', 'personal'), + folder('bin', 'Archive Bin', null, { system_key: 'archive' }), + folder('old', 'Old Alpha', 'bin'), +] + +describe('folderFilterIds', () => { + it('returns null (no filtering) for a blank query', () => { + expect(folderFilterIds(folders, '')).toBeNull() + expect(folderFilterIds(folders, ' ')).toBeNull() + }) + + it('keeps matches and every ancestor, but not siblings or other branches', () => { + const keep = folderFilterIds(folders, 'alpha')! + expect([...keep].sort()).toEqual(['alpha', 'bin', 'old', 'projects', 'work']) + }) + + it('matches case-insensitively and by substring', () => { + expect([...folderFilterIds(folders, 'REC')!]).toEqual(['recipes', 'personal']) + }) + + it('keeps a matching parent without pulling in its non-matching children', () => { + const keep = folderFilterIds(folders, 'work')! + expect([...keep]).toEqual(['work']) + }) + + it('is empty when nothing matches', () => { + expect(folderFilterIds(folders, 'zzz')!.size).toBe(0) + }) +}) + +describe('pruneForest', () => { + it('leaves only the kept branches', () => { + const pruned = pruneForest(buildForest(folders), folderFilterIds(folders, 'alpha')!) + const shape = (nodes: ReturnType): unknown[] => + nodes.map((n) => [n.folder.id, shape(n.children)]) + // Equal sort_order, so siblings fall back to name order: "Archive Bin" before "Work". + expect(shape(pruned)).toEqual([ + ['bin', [['old', []]]], + ['work', [['projects', [['alpha', []]]]]], + ]) + }) +}) + +describe('folderMenuTarget', () => { + const byId = indexById(folders) + + it('classifies the bin, things inside it, saved searches and ordinary folders', () => { + expect(folderMenuTarget(byId.get('bin')!, 'bin', byId).kind).toBe('bin') + expect(folderMenuTarget(byId.get('old')!, 'bin', byId).kind).toBe('archived') + expect(folderMenuTarget(folder('dyn', 'Saved', null, { search_query: 'tag:x' }), 'bin', byId).kind).toBe('dynamic') + expect(folderMenuTarget(byId.get('alpha')!, 'bin', byId).kind).toBe('normal') + }) +}) diff --git a/frontend/src/utils/folderTree.ts b/frontend/src/utils/folderTree.ts index 7761ffd..18029ec 100644 --- a/frontend/src/utils/folderTree.ts +++ b/frontend/src/utils/folderTree.ts @@ -75,6 +75,49 @@ export function ancestorIds(folderId: string, byId: Map, maxDept return out } +/** Ids of the folders a name filter keeps: every folder whose name contains `query` + * (case-insensitive) plus all their ancestors, so a match is always reachable from the + * root. Returns null for a blank query, meaning "no filtering — show everything". */ +export function folderFilterIds(folders: Folder[], query: string): Set | null { + const q = query.trim().toLowerCase() + if (!q) return null + const byId = indexById(folders) + const keep = new Set() + for (const f of folders) { + if (!f.name.toLowerCase().includes(q)) continue + keep.add(f.id) + for (const a of ancestorIds(f.id, byId)) keep.add(a) + } + return keep +} + +/** Drop every node of a nested forest whose folder isn't in `keep`. */ +export function pruneForest(forest: FolderNode[], keep: Set): FolderNode[] { + return forest + .filter((n) => keep.has(n.folder.id)) + .map((n) => ({ folder: n.folder, children: pruneForest(n.children, keep) })) +} + +/** What a folder context menu was opened on. `create` is "add something here" — the + * "All notes" root row (folderId null) or the background of the folder being viewed. */ +export type FolderMenuTarget = + | { kind: 'create'; folderId: string | null } + | { kind: 'normal' | 'archived' | 'dynamic'; folder: Folder } + | { kind: 'bin' } + +/** Which menu a folder gets: the Archive Bin, anything inside the bin, a saved-search + * folder (a leaf), or an ordinary folder. */ +export function folderMenuTarget( + folder: Folder, + archiveId: string | null, + byId: Map, +): FolderMenuTarget { + if (folder.id === archiveId) return { kind: 'bin' } + if (isDynamicFolder(folder)) return { kind: 'dynamic', folder } + if (isInArchive(folder.id, byId, archiveId)) return { kind: 'archived', folder } + return { kind: 'normal', folder } +} + /** True if folderId is the Archive Bin itself or lives anywhere inside it. */ export function isInArchive( folderId: string | null, diff --git a/frontend/src/views/ListView.tsx b/frontend/src/views/ListView.tsx index fb40331..58e12f1 100644 --- a/frontend/src/views/ListView.tsx +++ b/frontend/src/views/ListView.tsx @@ -15,6 +15,7 @@ import FolderBreadcrumb from '@/components/FolderBreadcrumb' import FolderPickerModal from '@/components/FolderPickerModal' import FolderCustomizeModal from '@/components/FolderCustomizeModal' import FolderTreePanel from '@/components/FolderTreePanel' +import FolderMenu, { type FolderMenuActions } from '@/components/FolderMenu' import ImportUrlModal from '@/components/ImportUrlModal' import BulkExportMenu from '@/components/BulkExportMenu' import AIConversationPanel from '@/components/AIConversationPanel' @@ -29,7 +30,7 @@ import { parseMarkdownFrontmatter, buildImportedMarkdown } from '@/utils/markdow import { rewriteImageUrls } from '@/utils/blocks' import { importUrlApi, type UrlExtractResult } from '@/api/importUrl' import { resolveFolderIcon } from '@/utils/folderIcons' -import { indexById, findArchiveFolder, isInArchive, ancestorIds } from '@/utils/folderTree' +import { indexById, findArchiveFolder, isInArchive, ancestorIds, folderMenuTarget, type FolderMenuTarget } from '@/utils/folderTree' import { notesApi } from '@/api/notes' import type { NoteListItem } from '@/api/notes' import type { Folder } from '@/api/folders' @@ -68,6 +69,8 @@ function DraggableNote({ note, cardView, children }: { note: NoteListItem; cardV ref={setNodeRef} {...attributes} {...listeners} + // Right-click on a card is the browser's; only the bare background opens the add menu. + onContextMenu={(e) => e.stopPropagation()} className={`${cardView ? `${CARD_MAX_WIDTH} min-w-0` : ''} ${isDragging ? 'opacity-50' : ''}`} > {children} @@ -104,6 +107,9 @@ export default function ListView() { const [viewMode, setViewMode] = useState(storedViewMode) const [panelOpen, setPanelOpen] = useState(false) const [fabMenuOpen, setFabMenuOpen] = useState(false) + // Right-click menu over the folder bar chips or the notes background. + const [folderMenu, setFolderMenu] = useState<{ target: FolderMenuTarget; x: number; y: number } | null>(null) + const closeFolderMenu = useCallback(() => setFolderMenu(null), []) const [activeDrag, setActiveDrag] = useState< { type: 'note'; label: string } | { type: 'folder'; folder: Folder } | null >(null) @@ -659,13 +665,34 @@ export default function ListView() { )) } + // One set of handlers behind every folder menu: the tree, the folder bar and the notes + // background. + const folderActions: FolderMenuActions = { + onNewSubfolder: handleNewSubfolder, + onNewDynamicFolder: handleNewDynamicFolder, + onNewNote: handleNewNoteInFolder, + onImport: handleImportToFolder, + onImportUrl: (id) => setImportUrlTarget(id), + onMove: (f) => setMoveTarget({ id: f.id }), + onCustomize: handleCustomizeFolder, + onDelete: handleDeleteFolder, + onEmptyArchive: handleEmptyArchive, + } + const folderBarProps = { folders: visibleSubfolders, onOpen: openFolder, onOpenDynamic: openDynamicFolder, - onMove: (f: Folder) => setMoveTarget({ id: f.id }), - onCustomize: handleCustomizeFolder, - onDelete: handleDeleteFolder, + onOpenMenu: (f: Folder, x: number, y: number) => + setFolderMenu({ target: folderMenuTarget(f, archiveId, foldersById), x, y }), + } + + // Right-click on empty space in the notes view offers the same add menu as the tree, + // aimed at the folder being viewed. Links keep the browser's menu (open in new tab…). + function handleBackgroundContextMenu(e: React.MouseEvent) { + if ((e.target as HTMLElement).closest('a')) return + e.preventDefault() + setFolderMenu({ target: { kind: 'create', folderId }, x: e.clientX, y: e.clientY }) } const newNotePath = folderId ? `/notes/new?folder=${folderId}` : '/notes/new' @@ -814,19 +841,11 @@ export default function ListView() { currentFolderId={folderId} onOpenFolder={openFolder} onOpenDynamic={openDynamicFolder} - onNewSubfolder={handleNewSubfolder} - onNewDynamicFolder={handleNewDynamicFolder} - onNewNote={handleNewNoteInFolder} - onImport={handleImportToFolder} - onImportUrl={(id) => setImportUrlTarget(id)} - onMove={(f) => setMoveTarget({ id: f.id })} - onCustomize={handleCustomizeFolder} - onDelete={handleDeleteFolder} - onEmptyArchive={handleEmptyArchive} + {...folderActions} draggingFolderId={activeDrag?.type === 'folder' ? activeDrag.folder.id : null} />
-
+
{deepLoading || (loading && notes.length === 0 && visibleSubfolders.length === 0 && !inDeepMode) ? (
{Array.from({ length: 6 }).map((_, i) => ( @@ -949,6 +968,16 @@ export default function ListView() { )} + {folderMenu && ( + + )} + {toast && (
{toast}