diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.ts b/frontend/src/documents/hooks/useDocumentsPanelProps.ts index 3fc93c0..124d0ab 100644 --- a/frontend/src/documents/hooks/useDocumentsPanelProps.ts +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.ts @@ -26,8 +26,10 @@ export interface UseDocumentsPanelPropsArgs { refreshCurrentFolder?: () => void | Promise; currentSubfolders?: unknown[]; documents?: unknown[]; + searchQuery?: string; searchResultIds?: Identifier[] | null; folderClickHandlers: FolderClickHandlers; + selectedFolder?: Identifier | 'root' | null; selectFolder?: (...args: unknown[]) => void; handleFolderDragStart?: (...args: unknown[]) => void; handleFolderDragEnd?: (...args: unknown[]) => void; @@ -41,6 +43,7 @@ export interface UseDocumentsPanelPropsArgs { handleDocumentDragEnd?: (...args: unknown[]) => void; searchLoading?: boolean; tagLookupById?: unknown; + activeTagFilters?: Identifier[]; activeCorrespondentFilters?: Identifier[]; ensureAssetUrl?: (...args: unknown[]) => void; getDocumentAsset?: (...args: unknown[]) => unknown; @@ -80,8 +83,10 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { refreshCurrentFolder, currentSubfolders, documents, + searchQuery, searchResultIds, folderClickHandlers, + selectedFolder, selectFolder, handleFolderDragStart, handleFolderDragEnd, @@ -95,6 +100,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { handleDocumentDragEnd, searchLoading, tagLookupById, + activeTagFilters, activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, @@ -131,7 +137,9 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { onRefresh: refreshCurrentFolder, subfolders: currentSubfolders, documents, + searchQuery, searchResultIds, + selectedFolder, onFolderSelect: selectFolder, onFolderDrop: folderClickHandlers.onDrop, onFolderDragOver: folderClickHandlers.onDragOver, @@ -148,6 +156,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { onDocumentDragEnd: handleDocumentDragEnd, isSearchLoading: searchLoading, tagLookupById, + activeTagFilters, + activeCorrespondentFilters, activeCorrespondentIds: activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, @@ -186,6 +196,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { documentsSortDirection, documentsSortField, documentsViewMode, + searchQuery, documentLookup, draggedDocumentIds, draggedFolderId, @@ -214,8 +225,11 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => { refreshCurrentFolder, searchLoading, searchResultIds, + selectedFolder, selectFolder, tagLookupById, + activeTagFilters, + activeCorrespondentFilters, tags, ensureAssetUrl, getDocumentAsset, diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index e5dd442..9e075a8 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -132,10 +132,10 @@ const DocumentsPanelInner: React.FC = ({ onBulkReanalyze, folderOptions, onMoveDocumentsToFolder, - searchQuery, - activeTagFilters, - activeCorrespondentFilters, - selectedFolder, + searchQuery = '', + activeTagFilters = [], + activeCorrespondentFilters = [], + selectedFolder = null, promoteSelectionOrder, onDocumentTagDrop, currentTenantId, @@ -811,6 +811,7 @@ const DocumentsPanelInner: React.FC = ({ onTagClick: toggleTagFilter, onCorrespondentClick: toggleCorrespondentFilter, activeCorrespondentIdSet: activeCorrespondentIdSet, + activeTagFilters, scrollRef, // Desk specific onDocumentStackSelect: handleDeskDocumentStackSelect, diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 6cbf61b..85519e3 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -350,6 +350,7 @@ const useDocumentsWorkspace = ({ searchResultIds, setSearchResultIds, searchLoading, + activeTagFilters, setActiveTagFilters, activeCorrespondentFilters, setActiveCorrespondentFilters, @@ -966,7 +967,7 @@ const useDocumentsWorkspace = ({ - const handlePromptCreateFolder = useCallback(async () => { + const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => { if (creatingFolder) { return; } @@ -981,7 +982,7 @@ const useDocumentsWorkspace = ({ } setCreatingFolder(true); try { - const success = await handleFolderCreate(trimmed); + const success = await handleFolderCreate(trimmed, parentId); if (!success) { setStatusMessage('Unable to create folder. Check the status message for details.', 'error'); } @@ -1198,8 +1199,10 @@ const useDocumentsWorkspace = ({ refreshCurrentFolder, currentSubfolders, documents: viewDocuments, + searchQuery, searchResultIds, folderClickHandlers, + selectedFolder, handleFolderDragStart, handleFolderDragEnd, draggedFolderId, @@ -1211,6 +1214,7 @@ const useDocumentsWorkspace = ({ handleDocumentDragEnd, searchLoading, tagLookupById, + activeTagFilters, activeCorrespondentFilters, ensureAssetUrl, getDocumentAsset, diff --git a/frontend/src/hooks/documents/useFolderTree.ts b/frontend/src/hooks/documents/useFolderTree.ts index b9b1e0e..b0fa3f7 100644 --- a/frontend/src/hooks/documents/useFolderTree.ts +++ b/frontend/src/hooks/documents/useFolderTree.ts @@ -170,7 +170,8 @@ const useFolderTree = ({ ], ); - const expandFolderAncestors = useCallback((targetId: FolderId | null) => { + const expandFolderAncestors = useCallback((targetId: FolderId | null, options: { includeSelf?: boolean } = {}) => { + const { includeSelf = true } = options; if (!targetId || targetId === 'root') { setFolderNodes((prev: Map) => { const root = prev.get('root'); @@ -186,6 +187,13 @@ const useFolderTree = ({ const next = new Map(prev); let currentId = targetId; let guard = 0; + + // If not including self, start from parent + if (!includeSelf) { + const node = next.get(currentId); + currentId = node?.parentId ?? 'root'; + } + while (currentId && guard < 32) { guard += 1; const node = next.get(currentId); diff --git a/frontend/src/hooks/documents/useFolderTreeActions.ts b/frontend/src/hooks/documents/useFolderTreeActions.ts index 96c2ff3..2443621 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.ts +++ b/frontend/src/hooks/documents/useFolderTreeActions.ts @@ -59,7 +59,7 @@ interface UseFolderTreeActionsOptions { setSelectedFolder: (folderId: FolderKey) => void; ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise; ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise; - expandFolderAncestors: (folderId: FolderKey) => void; + expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void; applySelectedFolder: (folderId: FolderKey, contents: any) => void; notifyApiError: (error: unknown, message?: string) => void; setStatusMessage: (message: string, level?: string) => void; @@ -250,7 +250,7 @@ const useFolderTreeActions = ({ const targetId = folderId && folderId !== 'root' ? folderId : 'root'; await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); + expandFolderAncestors(targetId, { includeSelf: false }); if (!navigate || immediate) { await loadFolder(targetId, { preserveSearch: isFilterActive }); @@ -327,7 +327,7 @@ const useFolderTreeActions = ({ ); const handleFolderCreate = useCallback( - async (name: string) => { + async (name: string, parentId?: FolderKey | null) => { if (!token) { setStatusMessage('Log in to create folders.', 'error'); return false; @@ -336,9 +336,14 @@ const useFolderTreeActions = ({ setStatusMessage('Folder name cannot be empty.', 'error'); return false; } + + const targetParentId = parentId !== undefined + ? (parentId === 'root' ? null : parentId) + : (selectedFolder === 'root' ? null : selectedFolder); + const payload = { name: name.trim(), - parent_id: selectedFolder === 'root' ? null : selectedFolder, + parent_id: targetParentId, }; setCreatingFolder(true); let succeeded = false; @@ -372,7 +377,11 @@ const useFolderTreeActions = ({ }); return next; }); - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + + // Refresh the parent folder to ensure consistency + const refreshTarget = targetParentId || 'root'; + await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 }); + await selectFolder(folderData.id, { immediate: true }); succeeded = true; return true; diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx index fae985d..1b0a20c 100644 --- a/frontend/src/sidebar/Sidebar.tsx +++ b/frontend/src/sidebar/Sidebar.tsx @@ -1,110 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react'; -import type { CSSProperties, ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import { - ChevronIcon, - TrashIcon, - EditIcon, - FolderIcon, - SidebarCollapseIcon, - LogoutIcon, - ChevronDownIcon, - SettingsIcon, - CheckIcon, - PlusIcon, - FolderPlusIcon, - RestoreIcon, - SunIcon, - MoonIcon, - DesktopIcon, - UploadIcon, - LogoIcon, - GithubIcon, - MatrixIcon, - WorldIcon, -} from '../ui/icons'; -import PanelHeader from '../ui/PanelHeader'; -import useFloatingMenu from '../ui/useFloatingMenu'; -import { useDocumentsFilter } from '../documents/context/DocumentsFilterContext'; +import React, { useRef } from 'react'; -import { getTagColorStyle } from '../utils/colors'; -import { useSidebarContext } from './SidebarContext'; import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext'; import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore'; import type { Identifier } from '../types/identifiers'; -import { THEME_MODE_LABELS, THEME_MODES } from '../constants/sidebar'; -interface CommunityLink { - label: string; - href: string; - title: string; - Icon: typeof GithubIcon; -} - -const COMMUNITY_LINKS: CommunityLink[] = [ - { - label: 'GitHub', - href: 'https://github.com/papercrate-dms/papercrate', - title: 'Open Papercrate on GitHub', - Icon: GithubIcon, - }, - { - label: 'Matrix', - href: 'https://matrix.to/#/#papercrate:matrix.org', - title: 'Join the Papercrate Matrix room', - Icon: MatrixIcon, - }, - { - label: 'Website', - href: 'https://papercrate.org', - title: 'Visit papercrate.org', - Icon: WorldIcon, - }, -]; - -type FolderIdentifier = Identifier | 'root'; - -interface FolderTreeNode { - id: FolderIdentifier; - name?: string; - children: FolderIdentifier[]; - hasChildren?: boolean; - loaded?: boolean; - expanded?: boolean; -} - -interface FolderNodeProps { - node: FolderTreeNode; - depth: number; - isSelected: boolean; - onToggle: (folderId: FolderIdentifier) => void; - onSelect: (folderId: FolderIdentifier) => void; - onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; - onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; - onDragLeave: (event: React.DragEvent) => void; - onDelete: (folderId: FolderIdentifier) => void; - onRename?: (folderId: FolderIdentifier, name: string) => void; - renderChildren: (ids: FolderIdentifier[], depth: number) => ReactNode; - onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; - onFolderDragEnd?: (event: React.DragEvent) => void; - draggingFolderId?: FolderIdentifier | null; -} - -interface TagEntry { - id: Identifier; - label: string; - color?: string | null; -} - -interface CorrespondentEntry { - id: Identifier; - name: string; -} - -interface TenantOption { - id?: Identifier | null; - name?: string | null; -} +import SidebarFolderList from './components/SidebarFolderList'; +import SidebarTagList, { TagEntry } from './components/SidebarTagList'; +import SidebarCorrespondentList, { CorrespondentEntry } from './components/SidebarCorrespondentList'; +import { FolderIdentifier, FolderTreeNode } from './components/SidebarFolderNode'; +import { TenantOption } from './components/SidebarMenu'; +import SidebarHeader from './components/SidebarHeader'; +import SidebarSearch from './components/SidebarSearch'; type UploadHandler = ( files: FileList | File[] | Iterable, @@ -124,7 +30,7 @@ interface SidebarProps { onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; onFolderDragEnd?: (event: React.DragEvent) => void; draggedFolderId?: FolderIdentifier | null; - onCreateFolder?: () => void; + onCreateFolder?: (parentId?: Identifier | null) => void; creatingFolder?: boolean; tags?: TagEntry[]; untaggedFilterId?: Identifier | null; @@ -143,133 +49,6 @@ interface SidebarProps { onUploadFiles?: UploadHandler; } -const FolderNode: React.FC = ({ - node, - depth, - isSelected, - onToggle, - onSelect, - onDrop, - onDragOver, - onDragLeave, - onDelete, - onRename, - renderChildren, - onFolderDragStart, - onFolderDragEnd, - draggingFolderId, -}) => { - const isRoot = node.id === 'root'; - const childIds = Array.isArray(node.children) ? node.children : []; - const hasChildren = Boolean(node.hasChildren || childIds.length); - const canToggle = hasChildren || !node.loaded; - const showChevron = canToggle; - const icon = showChevron ? : null; - const canDrag = !isRoot; - const isDragging = draggingFolderId === node.id; - const isExpanded = Boolean(node.expanded); - const rowClasses = ['folder-row']; - if (isSelected) { - rowClasses.push('active'); - } - - const handleToggleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - if (canToggle) { - onToggle(node.id); - } - }; - - return ( -
  • -
    onSelect(node.id)} - onDoubleClick={() => { - if (canToggle) { - onToggle(node.id); - } - }} - onDragOver={(event) => onDragOver(event, node.id)} - onDragLeave={onDragLeave} - onDrop={(event) => onDrop(event, node.id)} - onDragStart={(event) => { - if (!canDrag || !onFolderDragStart) return; - onFolderDragStart(event, node.id); - }} - onDragEnd={(event) => { - onFolderDragEnd?.(event); - }} - > - - {icon} - - - - - {node.name} - - - {node.id !== 'root' && ( -
    - - -
    - )} -
    - {isExpanded && childIds.length > 0 && ( -
      - {renderChildren(childIds, depth + 1)} -
    - )} -
  • - ); -}; - -interface FloatingMenuControls { - isOpen: boolean; - toggle: () => void; - close: () => void; - menuRef: React.RefObject; - menuStyle: CSSProperties | null; - updatePosition: () => void; -} - const Sidebar: React.FC = ({ folderNodes, onToggle, @@ -301,33 +80,10 @@ const Sidebar: React.FC = ({ onOpenSettings, onUploadFiles, }) => { - const { - neutralHue, - setNeutralHue, - resetNeutralHue, - neutralChroma, - setNeutralChroma, - resetNeutralChroma, - themeMode, - cycleThemeMode, - themeModes, - } = useSidebarContext(); const { sidebarSuppressed, - collapseSidebar, } = usePanelManager(); - const { - query: searchQuery, - activeTagIds = [], - activeCorrespondentIds = [], - isActive: isFilterActive, - setQuery: setFilterQuery, - submit: submitFilter, - clear: clearFilterState, - toggleTag: toggleTagFilter, - toggleCorrespondent: toggleCorrespondentFilter, - } = useDocumentsFilter(); - const uploadInputRef = useRef(null); + const sidebarRef = useRef(null); const { panelStyle: sidebarStyle, @@ -335,358 +91,6 @@ const Sidebar: React.FC = ({ isPanelResizing: isResizingSidebar, } = usePanelResizeBindings('sidebar', { panelRef: sidebarRef }); - const handleCollapse = useCallback(() => { - collapseSidebar('sidebar'); - }, [collapseSidebar]); - const neutralHueInputId = useId(); - const neutralChromaInputId = useId(); - const sortedCorrespondents = useMemo(() => { - if (!Array.isArray(correspondents)) { - return []; - } - return correspondents - .filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name)) - .slice() - .sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' })); - }, [correspondents]); - const activeCorrespondentSet = useMemo( - () => new Set(activeCorrespondentIds || []), - [activeCorrespondentIds], - ); - const handleToggleTag = useCallback( - (tagId: Identifier | null) => { - if (tagId == null) { - return; - } - toggleTagFilter(tagId); - }, - [toggleTagFilter], - ); - const activeTagSet = useMemo( - () => new Set(activeTagIds || []), - [activeTagIds], - ); - const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; - const untaggedButtonClassName = ['badge', 'tag-chip', 'tag-chip--untagged', untaggedActive ? 'active' : null] - .filter(Boolean) - .join(' '); - const getTagChipClassName = (isActive: boolean) => - ['badge', 'tag-chip', isActive ? 'active' : null] - .filter(Boolean) - .join(' '); - const handleManageTags = useCallback(() => { - onManageTags?.(); - }, [onManageTags]); - const handleManageCorrespondents = useCallback(() => { - onManageCorrespondents?.(); - }, [onManageCorrespondents]); - const handleCreateTag = useCallback(async () => { - const input = window.prompt('New tag name'); - if (!input) { - return; - } - const trimmed = input.trim(); - if (!trimmed) { - return; - } - try { - await onCreateTag?.(trimmed); - } catch (error: unknown) { - console.error('[sidebar] failed to create tag', error); - } - }, [onCreateTag]); - - const handleCreateCorrespondent = useCallback(async () => { - const input = window.prompt('New correspondent name'); - if (!input) { - return; - } - const trimmed = input.trim(); - if (!trimmed) { - return; - } - try { - await onCreateCorrespondent?.(trimmed); - } catch (error: unknown) { - console.error('[sidebar] failed to create correspondent', error); - } - }, [onCreateCorrespondent]); - - const handleCreateFolder = useCallback(() => { - if (creatingFolder) { - return; - } - onCreateFolder?.(); - }, [creatingFolder, onCreateFolder]); - - const handleUploadButtonClick = useCallback(() => { - if (!onUploadFiles || !uploadInputRef.current) { - return; - } - uploadInputRef.current.click(); - }, [onUploadFiles]); - - const handleUploadInputChange = useCallback( - (event: React.ChangeEvent) => { - const files = event.target?.files; - if (files && files.length && onUploadFiles) { - onUploadFiles(files, selectedFolder ?? 'root'); - } - if (event.target) { - event.target.value = ''; - } - }, - [onUploadFiles, selectedFolder], - ); - - const handleThemeAdjustmentsReset = useCallback(() => { - resetNeutralHue(); - resetNeutralChroma(); - }, [resetNeutralHue, resetNeutralChroma]); - - const handleNeutralHueChange = useCallback( - (value: string) => { - if (value === '') { - resetNeutralHue(); - return; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return; - } - setNeutralHue(parsed); - }, - [resetNeutralHue, setNeutralHue], - ); - - const handleNeutralChromaChange = useCallback( - (value: string) => { - if (value === '') { - resetNeutralChroma(); - return; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return; - } - setNeutralChroma(parsed); - }, - [resetNeutralChroma, setNeutralChroma], - ); - - const formatSliderValue = useCallback((value: number | string) => { - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return String(value); - } - return parsed.toFixed(2); - }, []); - - const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES; - const themeModeIndex = themeModeList.indexOf(themeMode as string); - const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; - const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; - const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; - const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system; - const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system; - const themeModeIcon = resolvedThemeMode === 'dark' - ? - : resolvedThemeMode === 'light' - ? - : ; - - const handleThemeModeToggle = useCallback(() => { - cycleThemeMode(); - }, [cycleThemeMode]); - - const themeMenuSection = - neutralHue != null - ? ( -
    -
    - Theme -
    - - -
    -
    - - -
    - ) - : null; - - const communityMenuFooter = COMMUNITY_LINKS.length - ? ( -
    - {COMMUNITY_LINKS.map(({ href, label, title, Icon }) => ( - - - {label} - - ))} -
    - ) - : null; - - const handleSearchInputChange = useCallback( - (event: React.ChangeEvent) => { - setFilterQuery(event.target.value); - }, - [setFilterQuery], - ); - const handleSearchFormSubmit = useCallback( - (event: React.FormEvent) => { - event.preventDefault(); - submitFilter(); - }, - [submitFilter], - ); - const handleSearchClear = useCallback(() => { - clearFilterState(); - }, [clearFilterState]); - const tenantButtonRef = useRef(null); - const { - isOpen: tenantMenuOpen, - toggle: toggleTenantMenuFloating, - close: closeTenantMenu, - menuRef: tenantMenuRef, - menuStyle: tenantMenuStyle, - updatePosition: refreshTenantMenuPosition, - } = useFloatingMenu({ - anchorRef: tenantButtonRef, - minWidth: 220, - offset: 6, - }) as FloatingMenuControls; - const showTenantList = tenants.length > 1; - const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`; - - const toggleTenantMenu = useCallback(() => { - if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) { - onSelectTenant(null, { refreshOnly: true }); - } - toggleTenantMenuFloating(); - }, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]); - - useEffect(() => { - if (tenantMenuOpen) { - refreshTenantMenuPosition(); - } - }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]); - - const handleTenantSelect = useCallback( - (tenant: TenantOption | null) => { - const targetId = tenant?.id || null; - if (!targetId) { - return; - } - closeTenantMenu(); - onSelectTenant?.(tenant); - }, - [closeTenantMenu, onSelectTenant], - ); - - const handleLogoutFromMenu = useCallback(() => { - closeTenantMenu(); - onLogout?.(); - }, [closeTenantMenu, onLogout]); - - const handleSettingsFromMenu = useCallback(() => { - closeTenantMenu(); - onOpenSettings?.(); - }, [closeTenantMenu, onOpenSettings]); - - const renderNodes = useCallback( - (ids: FolderIdentifier[], depth: number): ReactNode => - ids.map((id) => { - const node = folderNodes.get(id); - if (!node) return null; - return ( - - ); - }), - [ - folderNodes, - selectedFolder, - onToggle, - onSelect, - onDrop, - onDragOver, - onDragLeave, - onDeleteFolder, - onRenameFolder, - onFolderDragStart, - onFolderDragEnd, - draggedFolderId, - ], - ); - - const rootNode = folderNodes.get('root'); const sidebarClassNames = ['sidebar']; if (isResizingSidebar) { sidebarClassNames.push('sidebar--resizing'); @@ -695,64 +99,6 @@ const Sidebar: React.FC = ({ sidebarClassNames.push('sidebar--suppressed'); } - const tenantMenuContent = tenantMenuOpen && tenantMenuStyle - ? createPortal( -
    - {showTenantList ? ( -
    -
    Switch tenant
    -
    - {tenants.map((tenant) => { - const tenantId = tenant?.id || null; - const isActive = tenantId === activeTenantId; - const tenantLabel = tenant?.name || tenantId || 'Tenant'; - return ( - - ); - })} -
    -
    - ) : null} -
    -
    - - -
    -
    - {themeMenuSection} - {communityMenuFooter} -
    , - document.body, - ) - : null; - return ( ); }; export default Sidebar; -export { FolderNode }; diff --git a/frontend/src/sidebar/components/SidebarCorrespondentList.tsx b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx new file mode 100644 index 0000000..b7e4a08 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useMemo } from 'react'; +import { PlusIcon, SettingsIcon } from '../../ui/icons'; +import type { Identifier } from '../../types/identifiers'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +export interface CorrespondentEntry { + id: Identifier; + name: string; +} + +interface SidebarCorrespondentListProps { + correspondents: CorrespondentEntry[]; + onCreateCorrespondent?: (name: string) => Promise | void; + onManageCorrespondents?: () => void; +} + +const SidebarCorrespondentList: React.FC = ({ + correspondents, + onCreateCorrespondent, + onManageCorrespondents, +}) => { + const { + activeCorrespondentIds, + toggleCorrespondent: toggleCorrespondentFilter, + } = useDocumentsFilter(); + + const sortedCorrespondents = useMemo(() => { + if (!Array.isArray(correspondents)) { + return []; + } + return correspondents + .filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name)) + .slice() + .sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' })); + }, [correspondents]); + + const activeCorrespondentSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + + const handleCreateCorrespondent = useCallback(async () => { + const input = window.prompt('New correspondent name'); + if (!input) { + return; + } + const trimmed = input.trim(); + if (!trimmed) { + return; + } + try { + await onCreateCorrespondent?.(trimmed); + } catch (error: unknown) { + console.error('[sidebar] failed to create correspondent', error); + } + }, [onCreateCorrespondent]); + + const handleToggleCorrespondent = useCallback((correspondentId: Identifier | null) => { + toggleCorrespondentFilter(correspondentId); + }, [toggleCorrespondentFilter]); + + return ( +
    +
    +

    Correspondents

    +
    + + + {correspondents.length} +
    +
    +
      + {sortedCorrespondents.map((correspondent) => { + const isActive = activeCorrespondentSet.has(correspondent.id); + const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; + const handleSelect = () => { + const nextId = isActive ? null : correspondent.id; + handleToggleCorrespondent(nextId); + }; + return ( +
    • + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(); + } + }} + > + {correspondent.name} + +
    • + ); + })} +
    +
    + ); +}; + +export default SidebarCorrespondentList; diff --git a/frontend/src/sidebar/components/SidebarFolderList.tsx b/frontend/src/sidebar/components/SidebarFolderList.tsx new file mode 100644 index 0000000..8a95319 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarFolderList.tsx @@ -0,0 +1,110 @@ +import React, { useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { Identifier } from '../../types/identifiers'; +import { FolderPlusIcon } from '../../ui/icons'; +import FolderNode, { FolderIdentifier, FolderTreeNode } from './SidebarFolderNode'; + +interface SidebarFolderListProps { + folderNodes: Map; + selectedFolder: FolderIdentifier | null; + onToggle: (folderId: FolderIdentifier) => void; + onSelect: (folderId: FolderIdentifier) => void; + onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragLeave: (event: React.DragEvent) => void; + onDeleteFolder: (folderId: FolderIdentifier) => void; + onRenameFolder?: (folderId: FolderIdentifier, name: string) => void; + onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onFolderDragEnd?: (event: React.DragEvent) => void; + draggedFolderId?: FolderIdentifier | null; + onCreateFolder?: (parentId?: Identifier | null) => void; + creatingFolder?: boolean; +} + +const SidebarFolderList: React.FC = ({ + folderNodes, + selectedFolder, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDeleteFolder, + onRenameFolder, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onCreateFolder, + creatingFolder, +}) => { + const rootNode = folderNodes.get('root'); + + const renderNodes = useCallback( + (ids: FolderIdentifier[], depth: number): ReactNode => + ids.map((id) => { + const node = folderNodes.get(id); + if (!node) return null; + return ( + + ); + }), + [ + folderNodes, + selectedFolder, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDeleteFolder, + onRenameFolder, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onCreateFolder, + ], + ); + + return ( +
    +
    +

    Folders

    + {onCreateFolder ? ( +
    + +
    + ) : null} +
    +
      + {rootNode && renderNodes([rootNode.id], 0)} +
    +
    + ); +}; + +export default SidebarFolderList; diff --git a/frontend/src/sidebar/components/SidebarFolderNode.tsx b/frontend/src/sidebar/components/SidebarFolderNode.tsx new file mode 100644 index 0000000..c3eb9b4 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarFolderNode.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { + ChevronIcon, + TrashIcon, + EditIcon, + FolderIcon, + FolderPlusIcon, +} from '../../ui/icons'; +import type { Identifier } from '../../types/identifiers'; + +export type FolderIdentifier = Identifier | 'root'; + +export interface FolderTreeNode { + id: FolderIdentifier; + name?: string; + children: FolderIdentifier[]; + hasChildren?: boolean; + loaded?: boolean; + expanded?: boolean; +} + +export interface FolderNodeProps { + node: FolderTreeNode; + depth: number; + isSelected: boolean; + onToggle: (folderId: FolderIdentifier) => void; + onSelect: (folderId: FolderIdentifier) => void; + onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragLeave: (event: React.DragEvent) => void; + onDelete: (folderId: FolderIdentifier) => void; + onRename?: (folderId: FolderIdentifier, name: string) => void; + renderChildren: (ids: FolderIdentifier[], depth: number) => ReactNode; + onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onFolderDragEnd?: (event: React.DragEvent) => void; + draggingFolderId?: FolderIdentifier | null; + onCreateFolder?: (parentId?: Identifier | null) => void; +} + +const FolderNode: React.FC = ({ + node, + depth, + isSelected, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDelete, + onRename, + renderChildren, + onFolderDragStart, + onFolderDragEnd, + draggingFolderId, + onCreateFolder, +}) => { + const isRoot = node.id === 'root'; + const childIds = Array.isArray(node.children) ? node.children : []; + const hasChildren = Boolean(node.hasChildren || childIds.length); + const canToggle = hasChildren || !node.loaded; + const showChevron = canToggle; + const icon = showChevron ? : null; + const canDrag = !isRoot; + const isDragging = draggingFolderId === node.id; + const isExpanded = Boolean(node.expanded); + const rowClasses = ['folder-row']; + if (isSelected) { + rowClasses.push('active'); + } + + const handleToggleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (canToggle) { + onToggle(node.id); + } + }; + + return ( +
  • +
    onSelect(node.id)} + onDoubleClick={() => { + if (canToggle) { + onToggle(node.id); + } + }} + onDragOver={(event) => onDragOver(event, node.id)} + onDragLeave={onDragLeave} + onDrop={(event) => onDrop(event, node.id)} + onDragStart={(event) => { + if (!canDrag || !onFolderDragStart) return; + onFolderDragStart(event, node.id); + }} + onDragEnd={(event) => { + onFolderDragEnd?.(event); + }} + > + + {icon} + + + + + {node.name} + + +
    + + {node.id !== 'root' && ( + <> + + + + )} +
    +
    + {isExpanded && childIds.length > 0 && ( +
      + {renderChildren(childIds, depth + 1)} +
    + )} +
  • + ); +}; + +export default FolderNode; diff --git a/frontend/src/sidebar/components/SidebarHeader.tsx b/frontend/src/sidebar/components/SidebarHeader.tsx new file mode 100644 index 0000000..741fa0f --- /dev/null +++ b/frontend/src/sidebar/components/SidebarHeader.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import type { CSSProperties } from 'react'; +import { + SidebarCollapseIcon, + ChevronDownIcon, + UploadIcon, + LogoIcon, +} from '../../ui/icons'; +import PanelHeader from '../../ui/PanelHeader'; +import useFloatingMenu from '../../ui/useFloatingMenu'; +import type { Identifier } from '../../types/identifiers'; +import SidebarMenu, { TenantOption } from './SidebarMenu'; +import { usePanelManager } from '../../app/PanelManagerContext'; +import { FolderIdentifier } from './SidebarFolderNode'; + +type UploadHandler = ( + files: FileList | File[] | Iterable, + targetFolderId?: FolderIdentifier | null, +) => void; + +interface SidebarHeaderProps { + tenantName?: string | null; + tenants: TenantOption[]; + activeTenantId: Identifier | null; + onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void; + onOpenSettings?: () => void; + onLogout?: () => void; + onUploadFiles?: UploadHandler; + selectedFolder?: FolderIdentifier | null; +} + +interface FloatingMenuControls { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.RefObject; + menuStyle: CSSProperties | null; + updatePosition: () => void; +} + +const SidebarHeader: React.FC = ({ + tenantName, + tenants, + activeTenantId, + onSelectTenant, + onOpenSettings, + onLogout, + onUploadFiles, + selectedFolder, +}) => { + const { collapseSidebar } = usePanelManager(); + const uploadInputRef = useRef(null); + const tenantButtonRef = useRef(null); + + const { + isOpen: tenantMenuOpen, + toggle: toggleTenantMenuFloating, + close: closeTenantMenu, + menuRef: tenantMenuRef, + menuStyle: tenantMenuStyle, + updatePosition: refreshTenantMenuPosition, + } = useFloatingMenu({ + anchorRef: tenantButtonRef, + minWidth: 220, + offset: 6, + }) as FloatingMenuControls; + + const toggleTenantMenu = useCallback(() => { + if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) { + onSelectTenant(null, { refreshOnly: true }); + } + toggleTenantMenuFloating(); + }, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]); + + useEffect(() => { + if (tenantMenuOpen) { + refreshTenantMenuPosition(); + } + }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]); + + const handleCollapse = useCallback(() => { + collapseSidebar('sidebar'); + }, [collapseSidebar]); + + const handleUploadButtonClick = useCallback(() => { + if (!onUploadFiles || !uploadInputRef.current) { + return; + } + uploadInputRef.current.click(); + }, [onUploadFiles]); + + const handleUploadInputChange = useCallback( + (event: React.ChangeEvent) => { + const files = event.target?.files; + if (files && files.length && onUploadFiles) { + onUploadFiles(files, selectedFolder ?? 'root'); + } + if (event.target) { + event.target.value = ''; + } + }, + [onUploadFiles, selectedFolder], + ); + + return ( + <> + + + + + + )} + actions={( + <> + + + + )} + /> + + ); +}; + +export default SidebarHeader; diff --git a/frontend/src/sidebar/components/SidebarMenu.tsx b/frontend/src/sidebar/components/SidebarMenu.tsx new file mode 100644 index 0000000..1b3260c --- /dev/null +++ b/frontend/src/sidebar/components/SidebarMenu.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useId } from 'react'; +import type { CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { + GithubIcon, + MatrixIcon, + WorldIcon, + CheckIcon, + SettingsIcon, + LogoutIcon, + RestoreIcon, + SunIcon, + MoonIcon, + DesktopIcon, +} from '../../ui/icons'; +import { useSidebarContext } from '../SidebarContext'; +import { THEME_MODE_LABELS, THEME_MODES } from '../../constants/sidebar'; +import type { Identifier } from '../../types/identifiers'; + +export interface TenantOption { + id?: Identifier | null; + name?: string | null; +} + +interface CommunityLink { + label: string; + href: string; + title: string; + Icon: typeof GithubIcon; +} + +const COMMUNITY_LINKS: CommunityLink[] = [ + { + label: 'GitHub', + href: 'https://github.com/papercrate-dms/papercrate', + title: 'Open Papercrate on GitHub', + Icon: GithubIcon, + }, + { + label: 'Matrix', + href: 'https://matrix.to/#/#papercrate:matrix.org', + title: 'Join the Papercrate Matrix room', + Icon: MatrixIcon, + }, + { + label: 'Website', + href: 'https://papercrate.org', + title: 'Visit papercrate.org', + Icon: WorldIcon, + }, +]; + +interface SidebarMenuProps { + isOpen: boolean; + menuRef: React.RefObject; + style: CSSProperties | null; + tenants: TenantOption[]; + activeTenantId: Identifier | null; + onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void; + onOpenSettings?: () => void; + onLogout?: () => void; + onClose: () => void; +} + +const SidebarMenu: React.FC = ({ + isOpen, + menuRef, + style, + tenants, + activeTenantId, + onSelectTenant, + onOpenSettings, + onLogout, + onClose, +}) => { + const { + neutralHue, + setNeutralHue, + resetNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralChroma, + themeMode, + cycleThemeMode, + themeModes, + } = useSidebarContext(); + + const neutralHueInputId = useId(); + const neutralChromaInputId = useId(); + + const handleThemeAdjustmentsReset = useCallback(() => { + resetNeutralHue(); + resetNeutralChroma(); + }, [resetNeutralHue, resetNeutralChroma]); + + const handleNeutralHueChange = useCallback( + (value: string) => { + if (value === '') { + resetNeutralHue(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralHue(parsed); + }, + [resetNeutralHue, setNeutralHue], + ); + + const handleNeutralChromaChange = useCallback( + (value: string) => { + if (value === '') { + resetNeutralChroma(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralChroma(parsed); + }, + [resetNeutralChroma, setNeutralChroma], + ); + + const formatSliderValue = useCallback((value: number | string) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return String(value); + } + return parsed.toFixed(2); + }, []); + + const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES; + const themeModeIndex = themeModeList.indexOf(themeMode as string); + const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; + const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; + const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; + const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system; + const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system; + const themeModeIcon = resolvedThemeMode === 'dark' + ? + : resolvedThemeMode === 'light' + ? + : ; + + const handleThemeModeToggle = useCallback(() => { + cycleThemeMode(); + }, [cycleThemeMode]); + + const handleTenantSelect = useCallback( + (tenant: TenantOption | null) => { + const targetId = tenant?.id || null; + if (!targetId) { + return; + } + onClose(); + onSelectTenant?.(tenant); + }, + [onClose, onSelectTenant], + ); + + const handleLogoutFromMenu = useCallback(() => { + onClose(); + onLogout?.(); + }, [onClose, onLogout]); + + const handleSettingsFromMenu = useCallback(() => { + onClose(); + onOpenSettings?.(); + }, [onClose, onOpenSettings]); + + const themeMenuSection = + neutralHue != null + ? ( +
    +
    + Theme +
    + + +
    +
    + + +
    + ) + : null; + + const communityMenuFooter = COMMUNITY_LINKS.length + ? ( +
    + {COMMUNITY_LINKS.map(({ href, label, title, Icon }) => ( + + + {label} + + ))} +
    + ) + : null; + + const showTenantList = tenants.length > 1; + const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`; + + if (!isOpen || !style) { + return null; + } + + return createPortal( +
    + {showTenantList ? ( +
    +
    Switch tenant
    +
    + {tenants.map((tenant) => { + const tenantId = tenant?.id || null; + const isActive = tenantId === activeTenantId; + const tenantLabel = tenant?.name || tenantId || 'Tenant'; + return ( + + ); + })} +
    +
    + ) : null} +
    +
    + + +
    +
    + {themeMenuSection} + {communityMenuFooter} +
    , + document.body, + ); +}; + +export default SidebarMenu; diff --git a/frontend/src/sidebar/components/SidebarSearch.tsx b/frontend/src/sidebar/components/SidebarSearch.tsx new file mode 100644 index 0000000..9d268fb --- /dev/null +++ b/frontend/src/sidebar/components/SidebarSearch.tsx @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +const SidebarSearch: React.FC = () => { + const { + query: searchQuery, + isActive: isFilterActive, + setQuery: setFilterQuery, + submit: submitFilter, + clear: clearFilterState, + } = useDocumentsFilter(); + + const handleSearchInputChange = useCallback( + (event: React.ChangeEvent) => { + setFilterQuery(event.target.value); + }, + [setFilterQuery], + ); + + const handleSearchFormSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + submitFilter(); + }, + [submitFilter], + ); + + const handleSearchClear = useCallback(() => { + clearFilterState(); + }, [clearFilterState]); + + return ( +
    + + {isFilterActive && ( + + )} +
    + ); +}; + +export default SidebarSearch; + diff --git a/frontend/src/sidebar/components/SidebarTagList.tsx b/frontend/src/sidebar/components/SidebarTagList.tsx new file mode 100644 index 0000000..cd92dbb --- /dev/null +++ b/frontend/src/sidebar/components/SidebarTagList.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useMemo } from 'react'; +import { PlusIcon, SettingsIcon } from '../../ui/icons'; +import { getTagColorStyle } from '../../utils/colors'; +import type { Identifier } from '../../types/identifiers'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +export interface TagEntry { + id: Identifier; + label: string; + color?: string | null; +} + +interface SidebarTagListProps { + tags: TagEntry[]; + untaggedFilterId: Identifier | null; + onCreateTag?: (label: string) => Promise | void; + onManageTags?: () => void; +} + +const SidebarTagList: React.FC = ({ + tags, + untaggedFilterId, + onCreateTag, + onManageTags, +}) => { + const { + activeTagIds, + toggleTag: toggleTagFilter, + } = useDocumentsFilter(); + + const activeTagSet = useMemo( + () => new Set(activeTagIds || []), + [activeTagIds], + ); + + const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; + const untaggedButtonClassName = ['badge', 'tag-chip', 'tag-chip--untagged', untaggedActive ? 'active' : null] + .filter(Boolean) + .join(' '); + + const getTagChipClassName = (isActive: boolean) => + ['badge', 'tag-chip', isActive ? 'active' : null] + .filter(Boolean) + .join(' '); + + const handleCreateTag = useCallback(async () => { + const input = window.prompt('New tag name'); + if (!input) { + return; + } + const trimmed = input.trim(); + if (!trimmed) { + return; + } + try { + await onCreateTag?.(trimmed); + } catch (error: unknown) { + console.error('[sidebar] failed to create tag', error); + } + }, [onCreateTag]); + + const handleToggleTag = useCallback((tagId: Identifier | null) => { + if (tagId == null) return; + toggleTagFilter(tagId); + }, [toggleTagFilter]); + + return ( +
    +
    +

    Tags

    +
    + + + {tags.length} +
    +
    +
    + {untaggedFilterId ? ( + + ) : null} + {tags.map((tag) => { + const isActive = activeTagSet.has(tag.id); + const style = getTagColorStyle(tag.color); + const className = getTagChipClassName(isActive); + return ( + + ); + })} +
    +
    + ); +}; + +export default SidebarTagList; diff --git a/frontend/src/sidebar/useSidebarProps.ts b/frontend/src/sidebar/useSidebarProps.ts index 125b4fd..de68f59 100644 --- a/frontend/src/sidebar/useSidebarProps.ts +++ b/frontend/src/sidebar/useSidebarProps.ts @@ -76,7 +76,7 @@ interface UseSidebarPropsArgs { handleFolderDragStart: (event: DragEvent, folderId: Identifier) => void; handleFolderDragEnd: (event: DragEvent) => void; draggedFolderId: Identifier | null; - handlePromptCreateFolder?: () => void; + handlePromptCreateFolder?: (parentId?: Identifier | null) => void; creatingFolder: boolean; tags: TagOption[]; handleTagCreate: (payload: { label?: string }) => void | Promise; @@ -173,7 +173,7 @@ const useSidebarProps = ({ onFolderDragStart: handleFolderDragStart, onFolderDragEnd: handleFolderDragEnd, draggedFolderId, - onCreateFolder: handlePromptCreateFolder, + onCreateFolder: (parentId) => handlePromptCreateFolder?.(parentId), creatingFolder, tags, untaggedFilterId: TAG_FILTER_UNTAGGED,