Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions frontend/src/components/FolderFilterInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`relative ${className}`}>
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={value}
onChange={(e) => 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 && (
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600"
title="Clear filter"
aria-label="Clear filter"
onClick={() => onChange('')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}
83 changes: 17 additions & 66 deletions frontend/src/components/FolderIconBar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLDivElement>(null)
function FolderChip({ folder, onOpen, onOpenDynamic, onOpenMenu }: ChipProps) {
const btnRef = useRef<HTMLButtonElement>(null)

const isDynamic = isDynamicFolder(folder)
Expand All @@ -38,79 +31,39 @@ 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 (
<div
ref={setRefs}
{...attributes}
{...listeners}
className={`relative group flex flex-col items-center p-1.5 rounded-lg border shrink-0 cursor-pointer bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${isOver ? 'ring-2 ring-blue-500 ring-offset-1' : ''} ${isDragging ? 'opacity-30' : ''}`}
onClick={() => (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}
>
<FolderTile folder={folder} size={32} />
<button
ref={btnRef}
className={`absolute top-0.5 right-0.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 shrink-0 transition-opacity ${menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
className="absolute top-0.5 right-0.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 shrink-0 transition-opacity opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Folder actions"
onPointerDown={(e) => e.stopPropagation()}
onClick={openMenu}
onClick={(e) => {
e.stopPropagation()
if (!btnRef.current) return
const rect = btnRef.current.getBoundingClientRect()
onOpenMenu(folder, rect.left, rect.bottom + 4)
}}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{menuOpen && menuPos && createPortal(
<div
ref={menuRef}
className="fixed z-50 w-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 text-sm"
style={{ top: menuPos.top, right: menuPos.right }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => { setMenuOpen(false); onMove(folder) }}
>
<FolderInput className="w-4 h-4" /> Move to…
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => { setMenuOpen(false); onCustomize(folder) }}
>
<Palette className="w-4 h-4" /> Customize
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => { setMenuOpen(false); onDelete(folder) }}
>
<Trash2 className="w-4 h-4" /> Delete
</button>
</div>,
document.body
)}
</div>
)
}

export default function FolderIconBar({ folders, onOpen, onOpenDynamic, onMove, onCustomize, onDelete }: Props) {
export default function FolderIconBar({ folders, onOpen, onOpenDynamic, onOpenMenu }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)

useEffect(() => {
Expand Down Expand Up @@ -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}
/>
))}
</div>
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/components/FolderMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<button
key={key}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-gray-100 dark:hover:bg-gray-700 ${
danger ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-200'
}`}
onClick={() => { onClose(); onClick() }}
>
<Icon className="w-4 h-4 shrink-0" /> <span className="truncate">{label}</span>
</button>
)
}

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),
<div key="sep" className="my-1 border-t border-gray-100 dark:border-gray-700" />,
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(
<div
ref={ref}
className="fixed z-50 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 text-sm"
style={{ top: pos.top, left: pos.left }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation() }}
>
{items()}
</div>,
document.body,
)
}
18 changes: 15 additions & 3 deletions frontend/src/components/FolderPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@ interface Props {
export default function FolderPickerModal({ title = 'Move to folder', disabledIds, onSelect, onClose }: Props) {
const [folders, setFolders] = useState<Folder[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')

useEffect(() => {
let active = true
Expand All @@ -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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-xl p-5 max-w-sm w-full mx-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">{title}</h3>
<FolderFilterInput value={filter} onChange={setFilter} autoFocus className="mb-2" />
<div className="max-h-72 overflow-y-auto -mx-1">
<button
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
Expand All @@ -43,7 +53,9 @@ export default function FolderPickerModal({ title = 'Move to folder', disabledId
{loading ? (
<p className="text-sm text-gray-400 px-3 py-2">Loading…</p>
) : tree.length === 0 ? (
<p className="text-sm text-gray-400 px-3 py-2">No folders yet.</p>
<p className="text-sm text-gray-400 px-3 py-2">
{filterIds ? <>No folders match “{filter.trim()}”.</> : 'No folders yet.'}
</p>
) : (
tree.map((f) => {
const disabled = disabledIds?.has(f.id)
Expand Down
Loading
Loading