diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index ff1d38f..87e1c85 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -2,8 +2,10 @@ import React from 'react'; import { Navigate, Outlet } from 'react-router-dom'; import { AppShellContext } from '../appShellContext'; import DropOverlay from './DropOverlay'; +import UploadQueueOverlay from './UploadQueueOverlay'; import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; import { useDocumentsPreferences } from './useDocumentsPreferences'; +import SettingsRoute from './SettingsRoute'; const AppLayout = () => { const documentsPreferences = useDocumentsPreferences(); @@ -14,6 +16,8 @@ const AppLayout = () => { dropOverlayState, managementModals, contextValue, + settingsOpen, + closeSettings, } = useDocumentsWorkspace({ documentsViewMode: documentsPreferences.documentsViewMode, documentsSortField: documentsPreferences.documentsSortField, @@ -27,8 +31,6 @@ const AppLayout = () => { onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants, onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, - deskHelpOpen: documentsPreferences.deskHelpOpen, - setDeskHelpOpen: documentsPreferences.setDeskHelpOpen, handleDeskExit: documentsPreferences.handleDeskExit, }); @@ -54,8 +56,15 @@ const AppLayout = () => { active={dropOverlayState.active} folderName={dropOverlayState.folderName} /> + {managementModals} + {settingsOpen ? ( + + ) : null} ); diff --git a/frontend/src/app/AppRouter.jsx b/frontend/src/app/AppRouter.jsx index f077d05..fb5488b 100644 --- a/frontend/src/app/AppRouter.jsx +++ b/frontend/src/app/AppRouter.jsx @@ -3,7 +3,6 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import AppLayout from './AppLayout'; import DocumentsRoute from './DocumentsRoute'; import LoginRoute from './LoginRoute'; -import SettingsRoute from './SettingsRoute'; const AppRouter = () => ( @@ -13,7 +12,6 @@ const AppRouter = () => ( } /> } /> } /> - } /> } /> diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index 165bfa0..ce7cc6c 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -1,5 +1,4 @@ import React, { useEffect, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; import SettingsModal from '../settings/SettingsModal'; import { useAppShell } from '../appShellContext'; import useApiTokens from '../settings/useApiTokens'; @@ -7,8 +6,7 @@ import useCapabilitySets from '../settings/useCapabilitySets'; import useCapabilities from '../settings/useCapabilities'; import { api } from './appState'; -const SettingsRoute = () => { - const navigate = useNavigate(); +const SettingsRoute = ({ open = true, onClose }) => { const { token, notifyApiError, @@ -71,27 +69,20 @@ const SettingsRoute = () => { const handleClose = useCallback(() => { dismissSecret(); - navigate(-1); - }, [dismissSecret, navigate]); - - useEffect(() => { - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - event.preventDefault(); - handleClose(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleClose]); + onClose?.(); + }, [dismissSecret, onClose]); useEffect(() => () => { dismissSecret(); }, [dismissSecret]); + if (!open) { + return null; + } + return ( , + }, + uploading: { + label: 'Uploading', + tone: 'accent', + icon: , + }, + success: { + label: 'Uploaded', + tone: 'success', + icon: , + }, + duplicate: { + label: 'Duplicate', + tone: 'info', + icon: , + }, + error: { + label: 'Failed', + tone: 'danger', + icon: , + }, +}; + +const UploadQueueOverlay = ({ queue = [], onClearQueue }) => { + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (queue.length > 0) { + setDismissed(false); + } + }, [queue.length]); + + const summary = useMemo(() => { + if (!queue.length) { + return 'No uploads'; + } + const uploadingCount = queue.filter((item) => item.status === 'uploading').length; + const pendingCount = queue.filter((item) => item.status === 'pending').length; + const errorCount = queue.filter((item) => item.status === 'error').length; + if (uploadingCount > 0 || pendingCount > 0) { + return `${uploadingCount} uploading · ${pendingCount} queued`; + } + if (errorCount > 0) { + return `${errorCount} failed · ${queue.length} total`; + } + return `${queue.length} completed`; + }, [queue]); + + const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); + + const handleClearQueue = () => { + if (!onClearQueue || hasActiveUploads) { + return; + } + onClearQueue(); + }; + + if (!queue.length || dismissed) { + return null; + } + + return ( +
+ + Uploads + {summary} + + )} + actions={( +
+ + + +
+ )} + /> + {!collapsed ? ( +
    + {[...queue] + .slice() + .reverse() + .map((item) => { + const meta = STATUS_META[item.status] || STATUS_META.pending; + const fileLabel = item.name; + const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null; + const documentId = item.document?.id || item.conflictDocumentId || null; + const hasLink = Boolean(documentId); + const handleNavigate = () => { + if (!documentId) { + return; + } + navigate(`/documents/${documentId}`); + }; + return ( +
  • + + {meta.icon} + +
    + {item.status === 'success' && hasLink ? ( + + ) : ( +
    + {fileLabel} +
    + )} +
    + {item.status === 'duplicate' && duplicateLabel ? ( + + Duplicate of{' '} + + + ) : item.status === 'error' && item.error ? ( + + {item.error} + + ) : ( + <> + {meta.label} + {documentId ? ( + + ( + + ) + + ) : null} + + )} +
    +
    +
  • + ); + })} +
+ ) : null} +
+ ); +}; + +export default UploadQueueOverlay; diff --git a/frontend/src/app/useDocumentsPreferences.js b/frontend/src/app/useDocumentsPreferences.js index 89a139a..c02468f 100644 --- a/frontend/src/app/useDocumentsPreferences.js +++ b/frontend/src/app/useDocumentsPreferences.js @@ -42,17 +42,8 @@ export const useDocumentsPreferences = () => { return 'list'; }); - const [deskHelpOpen, setDeskHelpOpen] = useState(false); const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); - useEffect(() => { - if (documentsViewMode !== 'desk') { - lastNonDeskViewRef.current = documentsViewMode; - } else if (deskHelpOpen) { - setDeskHelpOpen(false); - } - }, [documentsViewMode, deskHelpOpen]); - const setDocumentsViewMode = useCallback((mode) => { const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list'; setDocumentsViewModeState((previous) => { @@ -122,8 +113,6 @@ export const useDocumentsPreferences = () => { documentsViewMode, handleDocumentsViewModeChange: setDocumentsViewMode, handleDeskExit, - deskHelpOpen, - setDeskHelpOpen, documentsSortField, documentsSortDirection, documentsSortFieldRef, diff --git a/frontend/src/app/useEntryPointerHandler.js b/frontend/src/app/useEntryPointerHandler.js deleted file mode 100644 index 3fa1c29..0000000 --- a/frontend/src/app/useEntryPointerHandler.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback } from 'react'; -import { useEntryPointerHandler as useEntryPointerCore, isPointerModifierEvent, isPrimaryPointerEvent } from '../documents/useEntryPointer'; - -export const useEntryPointer = ({ - resolveDocumentRowKey, - resolveFolderRowKey, - onSelectDocument, - onInspectDocument, - onSelectFolder, -}) => { - const coreHandler = useEntryPointerCore({ - resolveDocumentRowKey, - resolveFolderRowKey, - onSelectDocument: (documentId, event, meta) => { - const { modifierClick, primaryClick, rowKey } = meta; - onSelectDocument(documentId, event, { modifierClick, primaryClick, rowKey }); - if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { - onInspectDocument(documentId, meta); - } - }, - onSelectFolder, - }); - - return useCallback((entry, event) => { - if (!entry) { - return; - } - if (entry.type !== 'document') { - coreHandler(entry, event); - return; - } - - const modifierClick = isPointerModifierEvent(event); - const primaryClick = isPrimaryPointerEvent(event); - - onSelectDocument(entry.id, event, { - modifierClick, - primaryClick, - rowKey: entry.key, - }); - - if (!modifierClick && primaryClick) { - onInspectDocument?.(entry.id, { modifierClick, primaryClick, rowKey: entry.key }); - } - }, [coreHandler, onInspectDocument, onSelectDocument]); -}; - -export default useEntryPointer; diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index c3ec2db..81fcb80 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; -import { createDesktopSurface } from '../desktop/DesktopWorkspace'; +import createDesktopSurface from '../desktop/createDesktopSurface'; export const useWorkspaceSurface = ({ sidebarCollapsed, diff --git a/frontend/src/desktop/DesktopWorkspace.css b/frontend/src/desktop/DesktopWorkspace.css deleted file mode 100644 index 30975f0..0000000 --- a/frontend/src/desktop/DesktopWorkspace.css +++ /dev/null @@ -1,400 +0,0 @@ -/* Desktop workspace styles */ -.desk-main { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.desk-item { - position: absolute; - display: block; - width: auto; - cursor: grab; - touch-action: none; - transform-origin: center center; - transition: box-shadow 0.16s ease; - outline: none; - will-change: transform; - -webkit-user-select: none; - user-select: none; - -webkit-touch-callout: none; -} - -.desk-shell { - flex: 1; - display: flex; - flex-direction: column; - grid-column: 2 / -1; - min-height: 0; - position: relative; -} - -.desk-canvas { - flex: 1; - position: relative; - overflow: hidden; - margin: 0; - outline: none; -} - -.desk-canvas:focus, -.desk-canvas:focus-visible { - outline: none; -} - -.desk-empty { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - text-align: center; - color: var(--muted); - font-size: 0.95rem; -} - - - -.desk-item__body { - flex-grow: 1; - width: 100%; - height: 100%; -} - -.desk-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 4px; -} - -.desk-item.is-dragging { - cursor: grabbing; - transition: none; -} - -.desk-item.is-tag-target .desk-item__card { - outline: 0.35rem dashed var(--accent); - outline-offset: 0.35rem; -} - -.desk-item.is-tag-pending .desk-item__card { - outline: 0.25rem solid var(--accent-outline); - outline-offset: 0.25rem; -} - -.desk-item.is-filtered-out { - opacity: 0.12; - pointer-events: none; - filter: blur(15px) grayscale(100%); - transition: opacity 0.6s ease, filter 0.28s ease; - z-index: 0 !important; -} - -.desk-item.is-selected { - z-index: 5; -} - -.desk-item.is-selected .desk-item__card { - box-shadow: - 0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent), - 0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent), - 0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent), - 0 10px 24px var(--shadow-medium); -} - -.desk-item.is-selected .desk-item__title { - color: var(--accent); -} - -.desk-item__tags { - position: absolute; - top: 0; - right: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; - align-items: flex-end; - transform-origin: top right; - transform: translate(-0.5em, 0.5em); - transition: transform 0.28s ease; -} - -.desk-item__correspondents { - position: absolute; - bottom: 0; - left: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; - align-items: flex-start; - transform-origin: bottom left; - transform: translate(0.5em, -0.5em); - pointer-events: none; -} - -.desk-correspondent-chip { - pointer-events: none; - font-size: 0.82rem; - padding: 0.18rem 0.55rem; - max-width: min(16rem, 80%); - display: inline-flex; - align-items: center; - overflow: hidden; - background: color-mix(in oklch, var(--surface-subtle) 90%, transparent); - color: var(--muted); -} - -.desk-correspondent-chip__label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.tag-chip--draggable { - user-select: none; - pointer-events: auto; - cursor: grab; - transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; -} - -.desk-help-overlay { - position: fixed; - inset: 0; - z-index: 5000000; - display: flex; - align-items: center; - justify-content: center; - padding: clamp(1.5rem, 4vw, 3rem); - pointer-events: none; -} - -.desk-help-overlay__backdrop { - position: absolute; - inset: 0; - background: var(--surface-overlay); - backdrop-filter: blur(8px); - pointer-events: auto; -} - -.desk-help-overlay__content { - position: relative; - width: min(640px, 92vw); - max-height: min(80vh, 640px); - background: var(--surface); - border: 1px solid var(--border); - box-shadow: 0 24px 64px var(--shadow-strong); - border-radius: 1.25rem; - display: flex; - flex-direction: column; - pointer-events: auto; - overflow: hidden; -} - -.desk-help-overlay__header, -.desk-help-overlay__footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.25rem; -} - -.desk-help-overlay__header { - border-bottom: 1px solid var(--border); -} - -.desk-help-overlay__header h2 { - margin: 0; - font-size: 1.15rem; - font-weight: 600; -} - -.desk-help-overlay__body { - padding: 1rem 1.25rem 1.5rem; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 0.75rem; - line-height: 1.55; -} - -.desk-help-overlay__body p { - margin: 0; - color: var(--muted); -} - -.desk-help-overlay__list { - margin: 0; - padding-left: 1.1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.desk-help-overlay__list li { - color: var(--fg); -} - -.desk-help-overlay__body kbd { - display: inline-block; - padding: 0.15rem 0.4rem; - border-radius: 0.4rem; - border: 1px solid var(--border-strong); - background: var(--surface-subtle); - font-size: 0.85em; - line-height: 1; - font-family: inherit; -} - -.desk-help-overlay__footer { - border-top: 1px solid var(--border); - justify-content: flex-end; - gap: 0.5rem; -} - -.tag-chip--draggable:active { - cursor: grabbing; -} - -.tag-chip--draggable.is-drag-hidden { - opacity: 0.4; -} - -.desk-item__tags .tag-chip { - font-size: 0.85rem; - padding: 0.18rem 0.55rem; - gap: 0.3rem; -} - - -.desk-card__nav { - position: absolute; - bottom: 1.8rem; - left: 50%; - transform: translateX(-50%); - transform-origin: center; - display: flex; - gap: 1.5rem; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; -} - -.desk-item__card:hover .desk-card__nav { - opacity: 1; - pointer-events: auto; -} - -.desk-card__nav-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.4em; - height: 2.4em; - padding: 0.25em; - border-radius: 50%; - border: none; - background: var(--preview-nav-bg); - color: var(--preview-nav-fg); - cursor: pointer; - transition: background 0.15s ease, opacity 0.15s ease; -} - -.desk-card__nav-button:hover:not([disabled]) { - background: var(--preview-nav-bg-hover); -} - -.desk-card__nav-button:disabled { - opacity: 0.4; - cursor: default; -} - -.desk-card__nav-button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.desk-card__nav-button svg { - width: 100%; - height: 100%; -} - -.desk-item__tags .tag-chip--tear-pending { - opacity: 0.35; -} - -body.desk-cursor-remove, -body.desk-cursor-remove * { - cursor: not-allowed !important; -} - -.desk-item__shadow { - display: none; -} - -.desk-item__card { - position: relative; - border-radius: 0; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - box-shadow: 0 12px 32px var(--shadow-medium); - overflow: hidden; -} - -.desk-item__card img { - width: 100%; - height: 100%; - object-fit: contain; - display: block; - pointer-events: none; - user-select: none; - -webkit-user-drag: none; -} - -.desk-item__card--empty { - box-shadow: 0 12px 32px var(--shadow-medium); - background: - radial-gradient(circle at 42% 38%, color-mix(in oklch, var(--surface-subtle) 75%, var(--selection) 25%), color-mix(in oklch, var(--surface-subtle) 85%, var(--selection) 15%) 70%), - linear-gradient(135deg, color-mix(in oklch, var(--surface-subtle) 88%, var(--selection-soft) 12%) 0%, color-mix(in oklch, var(--surface-subtle) 65%, var(--shadow-faint) 35%) 100%); - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.desk-item__placeholder { - font-size: 0.95rem; - font-weight: 500; - letter-spacing: normal; - color: var(--muted); -} - -.desk-item__empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - gap: 0.5rem; - padding: 1rem; - text-align: center; -} - -.desk-item__title { - font-size: 0.95rem; - font-weight: 500; - color: var(--fg); - max-width: 90%; - overflow: hidden; - overflow-wrap: anywhere; - word-break: break-word; - white-space: normal; -} diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx index ad65cd2..7b94936 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -7,13 +7,7 @@ import React, { useState, useSyncExternalStore, } from 'react'; -import { createPortal } from 'react-dom'; -import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; -import { CloseIcon } from '../ui/icons'; -import SelectionFloatingActions from '../documents/SelectionFloatingActions'; -import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; -import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; +import { resolveDocumentAssetUrl } from '../asset_manager'; import { formatTransform } from './math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; @@ -29,91 +23,13 @@ import { import useDeskPointer from './pointer/useDeskPointer'; import useDeskTagInteractions from './tags/useDeskTagInteractions'; import DesktopDocumentCard from './DesktopDocumentCard'; -import './DesktopWorkspace.css'; +import usePreviewMetadata from './hooks/usePreviewMetadata'; +import '../styles/workspace/workspace-layout.css'; +import '../styles/workspace/workspace-items.css'; +import '../styles/workspace/workspace-cards.css'; const DEBUG_DRAG = false; const DEBUG_FOCUS = false; - -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); - - useEffect(() => { - let cancelled = false; - const docs = Array.isArray(documents) ? documents : []; - if (!docs.length) { - setMetadataMap(new Map()); - return () => { - cancelled = true; - }; - } - - const fetchMetadataForDoc = async (doc) => { - if (!doc?.id) { - return null; - } - - const docId = String(doc.id); - const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); - - let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); - - const hasDimensions = (meta) => - Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && - Number(meta.width) > 0 && Number(meta.height) > 0; - - if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { - try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); - if (ensured) { - asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); - } - } catch (error) { - console.warn('[desk] ensureDocumentSize metadata fetch failed', error); - } - } - - if (!hasDimensions(metadata)) { - return null; - } - - const width = Number(metadata.width); - const height = Number(metadata.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return [docId, { width, height }]; - }; - - Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) - .then((entries) => { - if (cancelled) return; - const next = new Map(); - entries.forEach((entry) => { - if (entry) { - next.set(entry[0], entry[1]); - } - }); - setMetadataMap(next); - }) - .catch(() => { - if (!cancelled) { - setMetadataMap(new Map()); - } - }); - - return () => { - cancelled = true; - }; - }, [documents, getDocumentAsset, ensureAssetUrl]); - - return metadataMap; -}; - const DesktopWorkspace = ({ documents = [], searchResults = null, @@ -131,8 +47,6 @@ const DesktopWorkspace = ({ onClearSelection = null, detailPanelOpen = false, onCloseDetailPanel = null, - helpOpen = false, - onHelpClose = null, tenantId = null, viewId = 'default', }) => { @@ -722,12 +636,7 @@ const DesktopWorkspace = ({ visibleDocIds, ], ); - return ( - <> - - - - ); + return ; }; const DesktopWorkspaceView = ({ @@ -978,213 +887,3 @@ const DesktopWorkspaceView = ({ }; export default DesktopWorkspace; - -const DesktopHelpOverlay = ({ open = false, onClose = null }) => { - const portalTarget = typeof document !== 'undefined' ? document.body : null; - const closeButtonRef = useRef(null); - const previousFocusRef = useRef(null); - - const handleClose = useCallback(() => { - if (typeof onClose === 'function') { - onClose(); - } - }, [onClose]); - - useEffect(() => { - if (!open || typeof window === 'undefined') { - return undefined; - } - - const handleKeyDown = (event) => { - if (!event) { - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - handleClose(); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [open, handleClose]); - - useEffect(() => { - if (!open) { - const previous = previousFocusRef.current; - if (previous && typeof previous.focus === 'function') { - previous.focus(); - } - previousFocusRef.current = null; - return; - } - - if (typeof document !== 'undefined') { - previousFocusRef.current = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - } - - if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { - closeButtonRef.current.focus(); - } - }, [open]); - - if (!open || !portalTarget) { - return null; - } - - return createPortal( -
-
-
-
-

Desk view tips

- -
-
-

Use the desk as a freeform workspace for triage and quick comparisons.

-
    -
  • Single-click a document to open it in the detail panel.
  • -
  • Double-click to open the zoomed preview.
  • -
  • - Drag selected cards to reposition them; build a selection with - {' '} - Cmd/Ctrl - {' '}+ click or Shift-click. -
  • -
  • - Cmd/Ctrl + click with an empty selection scoops up the stack under - {' '}the pointer. -
  • -
  • Space clears the current selection.
  • -
  • - Drag tags from the sidebar onto a card to assign them, or fling a - {' '}tag away to remove it. -
  • -
-
-
- -
-
-
, - portalTarget, - ); -}; - -export const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - } = workspaceProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js new file mode 100644 index 0000000..1018c94 --- /dev/null +++ b/frontend/src/desktop/createDesktopSurface.js @@ -0,0 +1,112 @@ +import React from 'react'; +import SelectionFloatingActions from '../documents/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; +import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; +import DetailPanel from '../detail/DetailPanel'; +import DesktopWorkspace from './DesktopWorkspace'; + +const createDesktopSurface = ({ + workspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailProps = null, + detailOpen = false, +}) => { + if (!workspaceProps) { + return null; + } + + const { + currentFolderName, + searchResults, + onRefresh, + viewMode, + onViewModeChange, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + } = workspaceProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ + viewMode: viewMode || 'desk', + onViewModeChange, + onRefresh, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + const surfaceConfig = createWorkspaceSurfaceConfig({ + key: 'workspace', + variant: 'workspace', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); + + return { + ...surfaceConfig, + supportsDetail: Boolean(detailProps), + }; +}; + +export default createDesktopSurface; diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.js b/frontend/src/desktop/hooks/usePreviewMetadata.js new file mode 100644 index 0000000..6078c21 --- /dev/null +++ b/frontend/src/desktop/hooks/usePreviewMetadata.js @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { createAssetView } from '../../asset_manager'; + +const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { + const [metadataMap, setMetadataMap] = useState(() => new Map()); + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type) => + (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); + + let asset = resolveAsset('preview') || resolveAsset('thumbnail'); + let view = createAssetView(asset); + let metadata = view.getPrimaryMetadata(); + + const hasDimensions = (meta) => + Number.isFinite(Number(meta?.width)) && + Number.isFinite(Number(meta?.height)) && + Number(meta.width) > 0 && + Number(meta.height) > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + try { + const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + if (ensured) { + asset = ensured; + view = createAssetView(asset); + metadata = view.getPrimaryMetadata(); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + return { + docId, + width: Number(metadata.width), + height: Number(metadata.height), + }; + }; + + let mounted = true; + (async () => { + const entries = await Promise.all(docs.map(fetchMetadataForDoc)); + if (!mounted || cancelled) { + return; + } + const next = new Map(); + entries.forEach((entry) => { + if (entry && entry.docId) { + next.set(entry.docId, entry); + } + }); + if (!cancelled) { + setMetadataMap(next); + } + })(); + + return () => { + cancelled = true; + mounted = false; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +export default usePreviewMetadata; diff --git a/frontend/src/desktop/math.js b/frontend/src/desktop/math.js index 8854f38..7c26bad 100644 --- a/frontend/src/desktop/math.js +++ b/frontend/src/desktop/math.js @@ -1,8 +1,4 @@ -export const clamp = (value, min, max) => { - if (value < min) return min; - if (value > max) return max; - return value; -}; +export { clamp } from '../utils/math'; export const formatTransform = (x, y, rotation = 0, scale = 1) => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.js index 32782c5..8d8f634 100644 --- a/frontend/src/desktop/useDeskWorkspaceProps.js +++ b/frontend/src/desktop/useDeskWorkspaceProps.js @@ -46,8 +46,6 @@ const useDeskWorkspaceProps = ({ searchQuery, activeCorrespondentFilters, selectedFolder, - setDeskHelpOpen, - deskHelpOpen, openDetailPanel, }) => { const handleDeskDocumentStackSelect = useCallback( @@ -107,14 +105,6 @@ const useDeskWorkspaceProps = ({ [openDetailPanel, selectedDocumentIds], ); - const handleDeskHelpOpen = useCallback(() => { - setDeskHelpOpen(true); - }, [setDeskHelpOpen]); - - const handleDeskHelpClose = useCallback(() => { - setDeskHelpOpen(false); - }, [setDeskHelpOpen]); - const deskViewId = useMemo(() => { if (showingSearchResults) { const trimmedQuery = searchQuery.trim(); @@ -148,9 +138,6 @@ const useDeskWorkspaceProps = ({ onEntryPointer: handleEntryPointerCore, onDocumentStackSelect: handleDeskDocumentStackSelect, onPromoteSelection: promoteSelectionOrder, - onOpenHelp: handleDeskHelpOpen, - helpOpen: deskHelpOpen, - onHelpClose: handleDeskHelpClose, tenantId: currentTenantId, viewId: deskViewId, selectedDocumentIds, @@ -193,9 +180,6 @@ const useDeskWorkspaceProps = ({ handleEntryPointerCore, handleDeskDocumentStackSelect, promoteSelectionOrder, - handleDeskHelpOpen, - deskHelpOpen, - handleDeskHelpClose, currentTenantId, deskViewId, selectedDocumentIds, diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index 384ff61..2426f2c 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -1,15 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; +import { clamp } from '../utils/math'; const noop = () => {}; -const clamp = (value, min, max) => { - if (value < min) return min; - if (value > max) return max; - return value; -}; - const ensureDocumentRoot = () => { if (typeof document === 'undefined') { return null; diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.jsx index 5d16acb..b040c32 100644 --- a/frontend/src/documents/DocumentSummarySection.jsx +++ b/frontend/src/documents/DocumentSummarySection.jsx @@ -2,50 +2,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EditIcon, IconX, PlusIcon } from '../ui/icons'; import QuickAddMenu from '../ui/QuickAddMenu'; import { getTagColorStyle } from '../utils/colors'; +import { + formatDate, + toDateInputValue, + toIssuedTimestamp, +} from '../utils/date'; import { describeDocumentSummary } from './documentSummary'; -const formatDate = (value) => { - if (!value) { - return null; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return null; - } - return date.toLocaleDateString(); -}; - -const toDateInputValue = (value) => { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - const timezoneOffset = date.getTimezoneOffset(); - const localDate = new Date(date.getTime() - timezoneOffset * 60000); - return localDate.toISOString().slice(0, 10); -}; - -const toIssuedTimestamp = (dateString, fallback) => { - if (!dateString) { - return null; - } - const base = fallback ? new Date(fallback) : new Date(); - if (Number.isNaN(base.getTime())) { - return null; - } - const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); - if (!year || !month || !day) { - return null; - } - - const candidate = new Date(base); - candidate.setUTCFullYear(year, month - 1, day); - return candidate.toISOString(); -}; - export const sortCorrespondents = (entries = []) => entries .filter((entry) => entry && entry.name) @@ -257,7 +220,10 @@ const DocumentSummarySection = ({ } return describeDocumentSummary(document); }, [document]); - const issuedDateLabel = useMemo(() => formatDate(document?.issued_at), [document?.issued_at]); + const issuedDateLabel = useMemo( + () => formatDate(document?.issued_at, { fallback: null }), + [document?.issued_at], + ); const editableTitle = Boolean(document && onUpdateTitle); const editableIssued = Boolean(document && onUpdateIssued); diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx index 9811e99..714b2d8 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.jsx @@ -1,23 +1,13 @@ import React from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; +import { formatDate } from '../utils/date'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; -const formatDate = (value) => { - if (!value) { - return "—"; - } - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return "—"; - } - return new Date(timestamp).toLocaleDateString(); -}; - const DocumentsList = ({ entries, focusedRowKey, diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index 740b423..d076f21 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -1,1056 +1,3 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ViewListIcon, - ViewGridIcon, - IconFileStack, - RefreshIcon, - MinusVerticalIcon, - InfoIcon, - FoldersIcon, - FoldersOffIcon, - SortAscendingLettersIcon, - SortDescendingLettersIcon, -} from '../ui/icons'; -import QuickAddMenu from '../ui/QuickAddMenu'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -import createWorkspaceSurfaceConfig from './workspaceHeader'; -import DetailPanel from '../detail/DetailPanel'; -import DocumentsGrid from './DocumentsGrid'; -import DocumentsList from './DocumentsList'; -import { isTagTransferEvent } from './tagTransfer'; -import SelectionFloatingActions from './SelectionFloatingActions'; -import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; -import { useAssetNavigator } from '../hooks/useAssetNavigator'; -import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer'; - -const DEFAULT_GRID_ICON_SIZE = 144; - -const EntryType = { - folder: 'folder', - document: 'document', -}; - -const SORT_OPTIONS = [ - { value: 'title', label: 'Title' }, - { value: 'issued_at', label: 'Issued date' }, - { value: 'created_at', label: 'Added' }, - { value: 'updated_at', label: 'Updated date' }, -]; - -const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { - const next = accumulator; - next[option.value] = option.label; - return next; -}, {}); - -const DocumentsPanel = ({ - currentFolderName, - breadcrumbs, - onRefresh, - subfolders, - documents, - searchResults, - isFilterActive = false, - onFolderSelect, - onFolderDrop, - onFolderDragOver, - onFolderDragLeave, - onFolderDragStart, - onFolderDragEnd, - draggedFolderId, - onFolderRename, - selectedFolderIds = [], - selectedDocumentIds = [], - focusedRowKey, - draggingDocumentIds = [], - onDocumentDragStart, - onDocumentDragEnd, - onDocumentRename, - onEntryPointer = null, - onEntrySelection = null, - onInspectDocument = null, - tagLookupById, - activeCorrespondentIds = [], - onFocusedRowChange, - ensureAssetUrl = null, - getDocumentAsset = () => null, - onTagClick, - onCorrespondentClick, - isSearchLoading = false, - onDocumentTagDrop, - viewMode = 'list', - onViewModeChange, - onClearSelection, - selectedEntries = [], - showHeader = true, -}) => { - const showingSearchResults = searchResults !== null; - const rows = showingSearchResults ? searchResults : documents; - - const entries = useMemo(() => { - const list = []; - if (!showingSearchResults) { - subfolders.forEach((folder) => { - if (!folder || !folder.id) { - return; - } - list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); - }); - } - rows.forEach((doc) => { - if (!doc || !doc.id) { - return; - } - list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); - }); - return list; - }, [showingSearchResults, subfolders, rows]); - - const selectedSet = useMemo( - () => new Set(selectedDocumentIds), - [selectedDocumentIds], - ); - const selectedFolderSet = useMemo( - () => new Set(selectedFolderIds || []), - [selectedFolderIds], - ); - const draggingSet = useMemo( - () => new Set(draggingDocumentIds || []), - [draggingDocumentIds], - ); - const activeCorrespondentIdSet = useMemo( - () => new Set(activeCorrespondentIds || []), - [activeCorrespondentIds], - ); - const scrollRef = useRef(null); - const suppressDocumentClickRef = useRef(false); - const [, forceVisibilityTick] = useState(0); - const lastScrollNodeRef = useRef(null); - const assignScrollRef = useCallback((node) => { - if (lastScrollNodeRef.current === node) { - return; - } - lastScrollNodeRef.current = node; - scrollRef.current = node; - if (node) { - forceVisibilityTick((value) => value + 1); - } - }, []); - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const [previewDocId, setPreviewDocId] = useState(null); - - const previewDoc = useMemo(() => { - if (!previewDocId) { - return null; - } - return rows.find((doc) => doc?.id === previewDocId) || null; - }, [previewDocId, rows]); - - useEffect(() => { - if (previewDocId && !previewDoc) { - setPreviewDocId(null); - } - }, [previewDocId, previewDoc]); - - const previewNavigator = useAssetNavigator({ - document: previewDoc, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - - const { - currentUrl: previewUrl, - canGoPrev: previewCanGoPrev, - canGoNext: previewCanGoNext, - goPrev: previewGoPrev, - goNext: previewGoNext, - } = previewNavigator; - - const previewDisplay = useMemo(() => { - if (!previewDoc || !previewUrl) { - return null; - } - return { - url: previewUrl, - alt: previewDoc.title, - canGoPrev: Boolean(previewCanGoPrev), - canGoNext: Boolean(previewCanGoNext), - goPrev: previewGoPrev, - goNext: previewGoNext, - }; - }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); - - const closePreviewOverlay = useCallback(() => { - setPreviewDocId(null); - }, []); - - const handleDocumentPreviewZoom = useCallback( - (doc) => { - if (!doc || !doc.id) { - return; - } - const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; - if (!previewAsset) { - return; - } - setPreviewDocId(doc.id); - }, - [getDocumentAsset], - ); - - const handleDocumentActivate = useCallback( - (doc, event) => { - if (!doc) { - return; - } - if (event) { - if (typeof event.preventDefault === 'function') { - event.preventDefault(); - } - if (typeof event.stopPropagation === 'function') { - event.stopPropagation(); - } - } - if (event?.altKey) { - handleDocumentPreviewZoom(doc); - return; - } - onInspectDocument?.(doc.id, event); - }, - [handleDocumentPreviewZoom, onInspectDocument], - ); - - const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); - const navigableRows = useMemo( - () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), - [entries], - ); - const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); - - const getEntryByKey = useCallback( - (rowKey) => entries.find((entry) => entry.key === rowKey) || null, - [entries], - ); - - const handlePanelFocus = useCallback(() => { - let resolvedKey = null; - - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - resolvedKey = focusedRowKey; - } - - if (!resolvedKey) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - resolvedKey = candidate; - break; - } - } - } - - if (!resolvedKey && navigableRows.length) { - resolvedKey = navigableRows[0].key; - } - - if (!resolvedKey) { - return; - } - - onFocusedRowChange?.(resolvedKey); - - if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { - onEntrySelection(resolvedKey, { - shiftKey: false, - preventDefault: () => {}, - }); - } - }, [ - focusedRowKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - selectedEntries, - selectedRowKeySet, - ]); - - const handlePanelKeyDown = useCallback( - (event) => { - const { key, shiftKey } = event; - const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; - if (!triggers.includes(key)) { - return; - } - - if (!navigableRows.length) { - return; - } - - event.preventDefault(); - - let activeKey = - focusedRowKey && navigableRowKeys.includes(focusedRowKey) - ? focusedRowKey - : null; - - if (!activeKey) { - if (selectedEntries.length) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - activeKey = candidate; - break; - } - } - } - - if (!activeKey) { - activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; - } - } - - const currentIndex = navigableRowKeys.indexOf(activeKey); - const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; - - if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { - if (activeRow) { - onEntrySelection?.(activeRow.key, event); - if (activeRow.type === EntryType.folder) { - onFolderSelect?.(activeRow.id); - } else { - const entry = getEntryByKey(activeRow.key); - if (entry?.document) { - handleDocumentPreviewZoom(entry.document); - } - } - } - return; - } - - let nextIndex = currentIndex; - if (key === 'ArrowDown') { - nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); - } else if (key === 'ArrowUp') { - nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); - } else if (key === 'Home') { - nextIndex = 0; - } else if (key === 'End') { - nextIndex = navigableRows.length - 1; - } - - if (nextIndex === -1 || nextIndex >= navigableRows.length) { - return; - } - - const targetRow = navigableRows[nextIndex]; - if (!targetRow) { - return; - } - - onFocusedRowChange?.(targetRow.key); - onEntrySelection?.(targetRow.key, { - shiftKey, - preventDefault: () => {}, - }); - }, - [ - focusedRowKey, - getEntryByKey, - navigableRowKeys, - navigableRows, - onEntrySelection, - onFocusedRowChange, - onFolderSelect, - selectedEntries, - handleDocumentPreviewZoom, - ], - ); - - const isListView = viewMode === 'list'; - const gridIconSize = DEFAULT_GRID_ICON_SIZE; - const handleSetViewMode = useCallback( - (nextMode) => { - if (!onViewModeChange) { - return; - } - onViewModeChange(nextMode); - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, - [onViewModeChange], - ); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [viewMode]); - const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); - const ensureFocusedRowVisible = useCallback(() => { - if (!focusedRowKey) return; - const container = scrollRef.current; - if (!container) return; - let selector = null; - if (focusedRowKey.startsWith('document:')) { - selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; - } else if (focusedRowKey.startsWith('folder:')) { - selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - if (!selector) { - return; - } - const row = container.querySelector(selector); - if (!row || !container.contains(row)) { - return; - } - - const header = container.querySelector('thead'); - const headerHeight = header ? header.getBoundingClientRect().height : 0; - const rowTop = row.offsetTop; - const rowBottom = rowTop + row.offsetHeight; - const visibleTop = container.scrollTop + headerHeight; - const visibleBottom = container.scrollTop + container.clientHeight; - - if (rowTop < visibleTop) { - container.scrollTop = Math.max(rowTop - headerHeight, 0); - return; - } - - if (rowBottom > visibleBottom) { - const nextScrollTop = rowBottom - container.clientHeight; - container.scrollTop = Math.max(nextScrollTop, 0); - } - }, [focusedRowKey]); - - useEffect(() => { - ensureFocusedRowVisible(); - }, [ensureFocusedRowVisible]); - - const activeDescendantId = useMemo(() => { - if (!focusedRowKey) return undefined; - if (focusedRowKey.startsWith('document:')) { - return `document-row-${focusedRowKey.slice('document:'.length)}`; - } - if (focusedRowKey.startsWith('folder:')) { - return `folder-row-${focusedRowKey.slice('folder:'.length)}`; - } - return undefined; - }, [focusedRowKey]); - - const handleDocumentTagDragOver = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDragLeave = useCallback( - (event) => { - if (!isTagDragEvent(event)) { - return; - } - if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { - return; - } - event.currentTarget.classList.remove('tag-drop-target'); - }, - [isTagDragEvent], - ); - - const handleDocumentTagDrop = useCallback( - (event, documentId) => { - if (!isTagDragEvent(event)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('tag-drop-target'); - const payload = - event.dataTransfer.getData('application/x-papercrate-tag') || - event.dataTransfer.getData('text/papercrate-tag'); - if (!payload) { - return; - } - try { - const parsed = JSON.parse(payload); - if (parsed?.id && onDocumentTagDrop) { - onDocumentTagDrop(documentId, parsed); - } - } catch (error) { - console.warn('[documents] Failed to parse tag drop payload', error); - } - }, - [isTagDragEvent, onDocumentTagDrop], - ); - - const handleDocumentClick = useCallback( - (doc, event) => { - if (!doc || suppressDocumentClickRef.current) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, - event, - ); - } - }, - [onEntryPointer], - ); - - const handleFolderClick = useCallback( - (folder, event) => { - if (!folder) { - return; - } - - if (typeof onEntryPointer === 'function') { - onEntryPointer( - { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, - event, - ); - } - - if ( - !isPointerModifierEvent(event) - && isPrimaryPointerEvent(event) - && scrollRef.current - ) { - scrollRef.current.focus({ preventScroll: true }); - onFocusedRowChange?.(`folder:${folder.id}`); - } - }, - [onEntryPointer, onFocusedRowChange], - ); - - const handleDocumentDragStartLocal = useCallback( - (event, doc) => { - suppressDocumentClickRef.current = true; - onDocumentDragStart?.(event, doc); - }, - [onDocumentDragStart], - ); - - const handleDocumentDragEndLocal = useCallback( - (event) => { - onDocumentDragEnd?.(event); - requestAnimationFrame(() => { - suppressDocumentClickRef.current = false; - }); - }, - [onDocumentDragEnd], - ); - - const hasDocumentEntries = useMemo( - () => entries.some((entry) => entry.type === EntryType.document), - [entries], - ); - const showTableRows = entries.length > 0; - const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; - const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; - const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); - const trailEntries = useMemo(() => { - if (!breadcrumbEntries.length) { - return [{ id: 'current-folder', label: currentFolderName }]; - } - const lastIndex = breadcrumbEntries.length - 1; - return breadcrumbEntries.map((crumb, index) => ({ - id: crumb.id ?? index, - label: crumb.name ?? crumb.label ?? crumb.title ?? '', - onClick: index < lastIndex && onFolderSelect - ? () => onFolderSelect(crumb.id) - : null, - })); - }, [breadcrumbEntries, currentFolderName, onFolderSelect]); - - return ( - <> -
- {showHeader ? ( -
-
-

- -

- {showingSearchResults && ( -
Search results
- )} -
-
-
- - - -
- -
-
- ) : null} - {showDefaultEmptyState ? ( -
-
- Drop files anywhere or onto a folder to upload documents. -
-
- ) : showGridSearchEmptyState ? ( -
-
- No documents match the current filters. -
-
- ) : showListSearchEmptyState ? ( -
-
No documents match the current filters.
-
- ) : ( -
-
{ - if (event.target === scrollRef.current) { - handlePanelFocus(); - } - }} - onKeyDown={(event) => { - if (event.target !== scrollRef.current) { - return; - } - handlePanelKeyDown(event); - }} - onClick={(event) => { - if (event.target === event.currentTarget) { - onClearSelection?.(); - } - }} - aria-activedescendant={isGridView ? undefined : activeDescendantId} - > - {isGridView ? ( - - ) : !showTableRows ? null : ( - - )} -
-
- )} -
- - - ); -}; - -export default DocumentsPanel; - -const SortFieldQuickMenu = ({ sortField, onChange }) => { - const currentOption = useMemo( - () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], - [sortField], - ); - - const options = useMemo( - () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), - [], - ); - - const handleSelect = useCallback( - (value, option) => { - if (typeof onChange !== 'function') { - return; - } - const nextValue = option?.id || option?.original?.id || value; - if (nextValue) { - onChange(nextValue); - } - }, - [onChange], - ); - - const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; - - return ( - - {label} - - )} - triggerAriaLabel={`Sort by ${label}`} - triggerTitle={`Sort by ${label}`} - placeholder="Select sort field" - menuMinWidth={200} - align="start" - positionStrategy="absolute" - /> - ); -}; - -export const createDocumentsTableHeaderActions = ({ - viewMode, - onViewModeChange, - onRefresh, - onShowDeskHelp = null, - sortField = 'title', - onSortFieldChange = null, - sortDirection = 'asc', - onSortDirectionToggle = null, - isFilterActive = false, - includeDescendants = true, - onToggleIncludeDescendants = null, -}) => { - const isListView = viewMode === 'list'; - const isGridView = viewMode === 'grid'; - const isDeskView = viewMode === 'desk'; - - const sortDirectionIsDesc = sortDirection === 'desc'; - const sortDirectionTitle = sortDirectionIsDesc - ? 'Sorting Z → A. Click to switch to ascending.' - : 'Sorting A → Z. Click to switch to descending.'; - const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' - ? ( - - ) - : null; - - const sortControls = typeof onSortFieldChange === 'function' - ? ( -
- - {typeof onSortDirectionToggle === 'function' ? ( - - ) : null} -
- ) - : null; - - return ( - <> - {isDeskView && typeof onShowDeskHelp === 'function' ? ( - <> - - - - ) : null} - {includeDescendantsToggle ? ( - <> - {includeDescendantsToggle} - - - ) : null} - {sortControls ? ( - <> - {sortControls} - - - ) : null} -
- - - -
- - - - ); -}; - -export const createDocumentsSurface = ({ - tableProps, - parentBreadcrumb, - onNavigateParent, - renderSidebarToggle, - detailProps, - detailOpen = false, -}) => { - const { - currentFolderName, - breadcrumbs, - searchResults, - isFilterActive, - viewMode, - onViewModeChange, - onRefresh, - sortField, - sortDirection, - onSortFieldChange, - onSortDirectionToggle, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - onInspectDocument, - } = tableProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - - const actions = createDocumentsTableHeaderActions({ - viewMode, - onViewModeChange, - onRefresh, - sortField, - onSortFieldChange, - sortDirection, - onSortDirectionToggle, - isFilterActive, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'documents', - variant: 'documents', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return surfaceConfig; -}; +export { default } from './panel/DocumentsPanel'; +export { default as createDocumentsSurface } from './panel/createDocumentsSurface'; +export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar'; diff --git a/frontend/src/documents/documentMetadata.js b/frontend/src/documents/documentMetadata.js index 0c57e3c..ad6f8f6 100644 --- a/frontend/src/documents/documentMetadata.js +++ b/frontend/src/documents/documentMetadata.js @@ -1,10 +1,4 @@ -const formatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); -}; +import { formatDateTime } from '../utils/date'; export const buildDocumentMetadataItems = (document) => { if (!document) { diff --git a/frontend/src/documents/documentSummary.js b/frontend/src/documents/documentSummary.js index 47d473e..7243a88 100644 --- a/frontend/src/documents/documentSummary.js +++ b/frontend/src/documents/documentSummary.js @@ -1,15 +1,5 @@ import { formatFileSize } from '../utils/format'; - -const defaultFormatDateTime = (value) => { - if (!value) { - return '—'; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '—'; - } - return date.toLocaleString(); -}; +import { formatDateTime as defaultFormatDateTime } from '../utils/date'; const coercePageCount = (metadata) => { const raw = metadata?.page_count; diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx new file mode 100644 index 0000000..a5d307b --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanel.jsx @@ -0,0 +1,725 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ViewListIcon, ViewGridIcon, IconFileStack } from '../../ui/icons'; +import BreadcrumbTrail from '../../ui/BreadcrumbTrail'; +import DocumentsGrid from '../DocumentsGrid'; +import DocumentsList from '../DocumentsList'; +import { isTagTransferEvent } from '../tagTransfer'; +import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay'; +import { useAssetNavigator } from '../../hooks/useAssetNavigator'; +import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer'; + +const DEFAULT_GRID_ICON_SIZE = 144; + +const EntryType = { + folder: 'folder', + document: 'document', +}; + +const DocumentsPanel = ({ + currentFolderName, + breadcrumbs, + onRefresh, + subfolders, + documents, + searchResults, + isFilterActive = false, + onFolderSelect, + onFolderDrop, + onFolderDragOver, + onFolderDragLeave, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onFolderRename, + selectedFolderIds = [], + selectedDocumentIds = [], + focusedRowKey, + draggingDocumentIds = [], + onDocumentDragStart, + onDocumentDragEnd, + onDocumentRename, + onEntryPointer = null, + onEntrySelection = null, + onInspectDocument = null, + tagLookupById, + activeCorrespondentIds = [], + onFocusedRowChange, + ensureAssetUrl = null, + getDocumentAsset = () => null, + onTagClick, + onCorrespondentClick, + isSearchLoading = false, + onDocumentTagDrop, + viewMode = 'list', + onViewModeChange, + onClearSelection, + selectedEntries = [], + showHeader = true, +}) => { + const showingSearchResults = searchResults !== null; + const rows = showingSearchResults ? searchResults : documents; + + const entries = useMemo(() => { + const list = []; + if (!showingSearchResults) { + subfolders.forEach((folder) => { + if (!folder || !folder.id) { + return; + } + list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); + }); + } + rows.forEach((doc) => { + if (!doc || !doc.id) { + return; + } + list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); + }); + return list; + }, [showingSearchResults, subfolders, rows]); + + const selectedSet = useMemo( + () => new Set(selectedDocumentIds), + [selectedDocumentIds], + ); + const selectedFolderSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); + const draggingSet = useMemo( + () => new Set(draggingDocumentIds || []), + [draggingDocumentIds], + ); + const activeCorrespondentIdSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + const scrollRef = useRef(null); + const suppressDocumentClickRef = useRef(false); + const [, forceVisibilityTick] = useState(0); + const lastScrollNodeRef = useRef(null); + const assignScrollRef = useCallback((node) => { + if (lastScrollNodeRef.current === node) { + return; + } + lastScrollNodeRef.current = node; + scrollRef.current = node; + if (node) { + forceVisibilityTick((value) => value + 1); + } + }, []); + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const [previewDocId, setPreviewDocId] = useState(null); + + const previewDoc = useMemo(() => { + if (!previewDocId) { + return null; + } + return rows.find((doc) => doc?.id === previewDocId) || null; + }, [previewDocId, rows]); + + useEffect(() => { + if (previewDocId && !previewDoc) { + setPreviewDocId(null); + } + }, [previewDocId, previewDoc]); + + const previewNavigator = useAssetNavigator({ + document: previewDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + + const { + currentUrl: previewUrl, + canGoPrev: previewCanGoPrev, + canGoNext: previewCanGoNext, + goPrev: previewGoPrev, + goNext: previewGoNext, + } = previewNavigator; + + const previewDisplay = useMemo(() => { + if (!previewDoc || !previewUrl) { + return null; + } + return { + url: previewUrl, + alt: previewDoc.title, + canGoPrev: Boolean(previewCanGoPrev), + canGoNext: Boolean(previewCanGoNext), + goPrev: previewGoPrev, + goNext: previewGoNext, + }; + }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); + + const closePreviewOverlay = useCallback(() => { + setPreviewDocId(null); + }, []); + + const handleDocumentPreviewZoom = useCallback( + (doc) => { + if (!doc || !doc.id) { + return; + } + const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; + if (!previewAsset) { + return; + } + setPreviewDocId(doc.id); + }, + [getDocumentAsset], + ); + + const handleDocumentActivate = useCallback( + (doc, event) => { + if (!doc) { + return; + } + if (event) { + if (typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + if (event?.altKey) { + handleDocumentPreviewZoom(doc); + return; + } + onInspectDocument?.(doc.id, event); + }, + [handleDocumentPreviewZoom, onInspectDocument], + ); + + const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); + const navigableRows = useMemo( + () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), + [entries], + ); + const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); + + const getEntryByKey = useCallback( + (rowKey) => entries.find((entry) => entry.key === rowKey) || null, + [entries], + ); + + const handlePanelFocus = useCallback(() => { + let resolvedKey = null; + + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + resolvedKey = focusedRowKey; + } + + if (!resolvedKey) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + onFocusedRowChange?.(resolvedKey); + + if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { + onEntrySelection(resolvedKey, { + shiftKey: false, + preventDefault: () => {}, + }); + } + }, [ + focusedRowKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + selectedEntries, + selectedRowKeySet, + ]); + + const handlePanelKeyDown = useCallback( + (event) => { + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + event.preventDefault(); + + let activeKey = + focusedRowKey && navigableRowKeys.includes(focusedRowKey) + ? focusedRowKey + : null; + + if (!activeKey) { + if (selectedEntries.length) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; + } + } + + const currentIndex = navigableRowKeys.indexOf(activeKey); + const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + if (activeRow) { + onEntrySelection?.(activeRow.key, event); + if (activeRow.type === EntryType.folder) { + onFolderSelect?.(activeRow.id); + } else { + const entry = getEntryByKey(activeRow.key); + if (entry?.document) { + handleDocumentPreviewZoom(entry.document); + } + } + } + return; + } + + let nextIndex = currentIndex; + if (key === 'ArrowDown') { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'ArrowUp') { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length) { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + onFocusedRowChange?.(targetRow.key); + onEntrySelection?.(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); + }, + [ + focusedRowKey, + getEntryByKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + onFolderSelect, + selectedEntries, + handleDocumentPreviewZoom, + ], + ); + + const isListView = viewMode === 'list'; + const gridIconSize = DEFAULT_GRID_ICON_SIZE; + const handleSetViewMode = useCallback( + (nextMode) => { + if (!onViewModeChange) { + return; + } + onViewModeChange(nextMode); + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, + [onViewModeChange], + ); + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [viewMode]); + const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); + const ensureFocusedRowVisible = useCallback(() => { + if (!focusedRowKey) return; + const container = scrollRef.current; + if (!container) return; + let selector = null; + if (focusedRowKey.startsWith('document:')) { + selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; + } else if (focusedRowKey.startsWith('folder:')) { + selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + if (!selector) { + return; + } + const row = container.querySelector(selector); + if (!row || !container.contains(row)) { + return; + } + + const header = container.querySelector('thead'); + const headerHeight = header ? header.getBoundingClientRect().height : 0; + const rowTop = row.offsetTop; + const rowBottom = rowTop + row.offsetHeight; + const visibleTop = container.scrollTop + headerHeight; + const visibleBottom = container.scrollTop + container.clientHeight; + + if (rowTop < visibleTop) { + container.scrollTop = Math.max(rowTop - headerHeight, 0); + return; + } + + if (rowBottom > visibleBottom) { + const nextScrollTop = rowBottom - container.clientHeight; + container.scrollTop = Math.max(nextScrollTop, 0); + } + }, [focusedRowKey]); + + useEffect(() => { + ensureFocusedRowVisible(); + }, [ensureFocusedRowVisible]); + + const activeDescendantId = useMemo(() => { + if (!focusedRowKey) return undefined; + if (focusedRowKey.startsWith('document:')) { + return `document-row-${focusedRowKey.slice('document:'.length)}`; + } + if (focusedRowKey.startsWith('folder:')) { + return `folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + return undefined; + }, [focusedRowKey]); + + const handleDocumentTagDragOver = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDragLeave = useCallback( + (event) => { + if (!isTagDragEvent(event)) { + return; + } + if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) { + return; + } + event.currentTarget.classList.remove('tag-drop-target'); + }, + [isTagDragEvent], + ); + + const handleDocumentTagDrop = useCallback( + (event, documentId) => { + if (!isTagDragEvent(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('tag-drop-target'); + const payload = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + if (!payload) { + return; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.id && onDocumentTagDrop) { + onDocumentTagDrop(documentId, parsed); + } + } catch (error) { + console.warn('[documents] Failed to parse tag drop payload', error); + } + }, + [isTagDragEvent, onDocumentTagDrop], + ); + + const handleDocumentClick = useCallback( + (doc, event) => { + if (!doc || suppressDocumentClickRef.current) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, + event, + ); + } + }, + [onEntryPointer], + ); + + const handleFolderClick = useCallback( + (folder, event) => { + if (!folder) { + return; + } + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, + event, + ); + } + + if ( + !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + && scrollRef.current + ) { + scrollRef.current.focus({ preventScroll: true }); + onFocusedRowChange?.(`folder:${folder.id}`); + } + }, + [onEntryPointer, onFocusedRowChange], + ); + + const handleDocumentDragStartLocal = useCallback( + (event, doc) => { + suppressDocumentClickRef.current = true; + onDocumentDragStart?.(event, doc); + }, + [onDocumentDragStart], + ); + + const handleDocumentDragEndLocal = useCallback( + (event) => { + onDocumentDragEnd?.(event); + requestAnimationFrame(() => { + suppressDocumentClickRef.current = false; + }); + }, + [onDocumentDragEnd], + ); + + const hasDocumentEntries = useMemo( + () => entries.some((entry) => entry.type === EntryType.document), + [entries], + ); + const showTableRows = entries.length > 0; + const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; + const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; + const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; + const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); + const trailEntries = useMemo(() => { + if (!breadcrumbEntries.length) { + return [{ id: 'current-folder', label: currentFolderName }]; + } + const lastIndex = breadcrumbEntries.length - 1; + return breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onFolderSelect + ? () => onFolderSelect(crumb.id) + : null, + })); + }, [breadcrumbEntries, currentFolderName, onFolderSelect]); + + return ( + <> +
+ {showHeader ? ( +
+
+

+ +

+ {showingSearchResults && ( +
Search results
+ )} +
+
+
+ + + +
+ +
+
+ ) : null} + {showDefaultEmptyState ? ( +
+
+ Drop files anywhere or onto a folder to upload documents. +
+
+ ) : showGridSearchEmptyState ? ( +
+
+ No documents match the current filters. +
+
+ ) : showListSearchEmptyState ? ( +
+
No documents match the current filters.
+
+ ) : ( +
+
{ + if (event.target === scrollRef.current) { + handlePanelFocus(); + } + }} + onKeyDown={(event) => { + if (event.target !== scrollRef.current) { + return; + } + handlePanelKeyDown(event); + }} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClearSelection?.(); + } + }} + aria-activedescendant={isGridView ? undefined : activeDescendantId} + > + {isGridView ? ( + + ) : !showTableRows ? null : ( + + )} +
+
+ )} +
+ + + ); +}; + +export default DocumentsPanel; diff --git a/frontend/src/documents/panel/DocumentsToolbar.jsx b/frontend/src/documents/panel/DocumentsToolbar.jsx new file mode 100644 index 0000000..083a314 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsToolbar.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { + ViewListIcon, + ViewGridIcon, + IconFileStack, + RefreshIcon, + MinusVerticalIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, +} from '../../ui/icons'; +import SortFieldQuickMenu from './SortFieldQuickMenu'; + +export const createDocumentsTableHeaderActions = ({ + viewMode, + onViewModeChange, + onRefresh, + sortField = 'title', + onSortFieldChange = null, + sortDirection = 'asc', + onSortDirectionToggle = null, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants = null, +}) => { + const isListView = viewMode === 'list'; + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + + const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' + ? ( + + ) + : null; + + const sortControls = typeof onSortFieldChange === 'function' + ? ( +
+ + {typeof onSortDirectionToggle === 'function' ? ( + + ) : null} +
+ ) + : null; + + return ( + <> + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + + + ) : null} + {sortControls ? ( + <> + {sortControls} + + + ) : null} +
+ + + +
+ + + + ); +}; + +export default createDocumentsTableHeaderActions; diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.jsx b/frontend/src/documents/panel/SortFieldQuickMenu.jsx new file mode 100644 index 0000000..35d5631 --- /dev/null +++ b/frontend/src/documents/panel/SortFieldQuickMenu.jsx @@ -0,0 +1,64 @@ +import React, { useCallback, useMemo } from 'react'; +import QuickAddMenu from '../../ui/QuickAddMenu'; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => { + const next = acc; + next[option.value] = option.label; + return next; +}, {}); + +const SortFieldQuickMenu = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value, option) => { + if (typeof onChange !== 'function') { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + + {label} + + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + +export default SortFieldQuickMenu; diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js new file mode 100644 index 0000000..1124db1 --- /dev/null +++ b/frontend/src/documents/panel/createDocumentsSurface.js @@ -0,0 +1,119 @@ +import React from 'react'; +import DetailPanel from '../../detail/DetailPanel'; +import SelectionFloatingActions from '../SelectionFloatingActions'; +import createWorkspaceSurfaceConfig from '../workspaceHeader'; +import DocumentsPanel from './DocumentsPanel'; +import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; + +const createDocumentsSurface = ({ + tableProps, + parentBreadcrumb, + onNavigateParent, + renderSidebarToggle, + detailProps, + detailOpen = false, +}) => { + const { + currentFolderName, + breadcrumbs, + searchResults, + isFilterActive, + viewMode, + onViewModeChange, + onRefresh, + sortField, + sortDirection, + onSortFieldChange, + onSortDirectionToggle, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + onInspectDocument, + } = tableProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + + const actions = createDocumentsTableHeaderActions({ + viewMode, + onViewModeChange, + onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + + return createWorkspaceSurfaceConfig({ + key: 'documents', + variant: 'documents', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); +}; + +export default createDocumentsSurface; diff --git a/frontend/src/documents/useEntryPointer.js b/frontend/src/documents/useEntryPointer.js index f2a4eea..826645f 100644 --- a/frontend/src/documents/useEntryPointer.js +++ b/frontend/src/documents/useEntryPointer.js @@ -14,11 +14,12 @@ export const isPrimaryPointerEvent = (event) => { return type === 'click' || type === 'pointerdown' || type === 'pointerup'; }; -export const useEntryPointerHandler = ({ +export const useEntryPointer = ({ resolveDocumentRowKey, resolveFolderRowKey, onSelectDocument, onSelectFolder, + onInspectDocument, }) => useCallback( (entry, event) => { @@ -39,16 +40,20 @@ export const useEntryPointerHandler = ({ const modifierClick = isPointerModifierEvent(event); const primaryClick = isPrimaryPointerEvent(event); + const metadata = { modifierClick, primaryClick, rowKey }; if (type === 'document') { if (typeof onSelectDocument === 'function') { - onSelectDocument(id, event, { modifierClick, primaryClick, rowKey }); + onSelectDocument(id, event, metadata); + } + if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { + onInspectDocument(id, metadata); } return; } if (typeof onSelectFolder === 'function') { - onSelectFolder(id, event, { modifierClick, primaryClick, rowKey }); + onSelectFolder(id, event, metadata); } }, [ @@ -56,7 +61,8 @@ export const useEntryPointerHandler = ({ resolveFolderRowKey, onSelectDocument, onSelectFolder, + onInspectDocument, ], ); -export default useEntryPointerHandler; +export default useEntryPointer; diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.js index a04bd22..50b6d2d 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.js +++ b/frontend/src/hooks/documents/useDocumentUploads.js @@ -2,6 +2,26 @@ import { useCallback, useRef, useState } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +const mapFilesToEntries = (filesInput) => { + if (!filesInput) { + return []; + } + const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput); + return files + .filter(Boolean) + .map((file) => { + const relativePath = + typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + return { file, segments }; + }); +}; + const useDocumentUploads = ({ apiClient, token, @@ -9,8 +29,6 @@ const useDocumentUploads = ({ currentFolderName, ensureFolderData, refreshCurrentFolder, - notifyApiError, - setStatusMessage, setLoading, shellRef, }) => { @@ -20,12 +38,13 @@ const useDocumentUploads = ({ }); const dragCounterRef = useRef(0); const folderPathCacheRef = useRef(new Map()); + const queueIdRef = useRef(0); + const [uploadQueue, setUploadQueue] = useState([]); const uploadFile = useCallback( async (file, targetFolderId) => { if (!file || file.size === 0) { - setStatusMessage('Skipped empty file.', 'error'); - return null; + return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; } const formData = new FormData(); @@ -37,22 +56,71 @@ const useDocumentUploads = ({ try { const { data, status } = await apiClient.post('/documents', formData); const duplicate = data?.reused || status === 200; - setStatusMessage( - duplicate - ? `${file.name} already exists; reused existing document.` - : `Uploaded ${file.name}`, - duplicate ? 'info' : 'success', - ); - return data; + const document = data?.document ?? data ?? null; + return { + document, + duplicate, + statusCode: status ?? (duplicate ? 200 : 201), + conflictDocumentId: null, + }; } catch (error) { + if (error.response?.status === 409) { + const conflictId = error.response?.data?.details?.conflict_document_id ?? null; + let conflictDocument = null; + if (conflictId) { + try { + const { data } = await apiClient.get(`/documents/${conflictId}`); + conflictDocument = data?.document ?? data ?? null; + } catch (fetchError) { + console.warn('[Uploads] failed to fetch conflict document', fetchError); + } + } + return { + document: conflictDocument, + duplicate: true, + statusCode: 409, + conflictDocumentId: conflictId, + }; + } const message = error.response?.data?.error || `Failed to upload ${file.name}.`; - notifyApiError(error, message); - throw error; + const wrapped = Object.assign(new Error(message), { response: error.response }); + throw wrapped; } }, - [apiClient, notifyApiError, setStatusMessage], + [apiClient], ); + const appendQueueItems = useCallback((entries, targetFolderId) => { + const baseId = Date.now(); + const items = entries.map(({ file }) => { + queueIdRef.current += 1; + return { + id: `upload-${baseId}-${queueIdRef.current}`, + name: file?.name || 'Unnamed file', + size: file?.size ?? null, + folderId: targetFolderId ?? selectedFolder ?? 'root', + status: 'pending', + error: null, + code: null, + document: null, + conflictDocumentId: null, + }; + }); + if (items.length) { + setUploadQueue((current) => [...current, ...items]); + } + return items; + }, [selectedFolder]); + + const updateQueueItem = useCallback((id, patch) => { + if (!id) { + return; + } + setUploadQueue((current) => + current.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + }, []); + const ensureFolderPathOnServer = useCallback( async (baseFolderId, segments) => { const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); @@ -200,10 +268,25 @@ const useDocumentUploads = ({ return results; }, []); - const handleFileDrop = useCallback( - async (dataTransfer, targetFolderId) => { + const uploadFileEntries = useCallback( + async (entries, targetFolderId) => { + if (!entries || !entries.length) { + console.warn('[Uploads] No files to upload.'); + return; + } + + const queueItems = appendQueueItems(entries, targetFolderId); + if (!token) { - setStatusMessage('Please log in before uploading.', 'error'); + queueItems.forEach((item) => { + const patch = { + status: 'error', + error: 'Please log in before uploading.', + code: null, + }; + updateQueueItem(item.id, patch); + Object.assign(item, patch); + }); return; } @@ -212,23 +295,17 @@ const useDocumentUploads = ({ try { folderPathCacheRef.current.clear(); - let extracted; - try { - extracted = await extractFilesFromDataTransfer(dataTransfer); - } catch (error) { - const message = error.message || 'Failed to process dropped files.'; - notifyApiError(error, message); - return; - } - - if (!extracted.length) { - setStatusMessage('No files to upload.', 'info'); - return; - } const baseFolderId = targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; - for (const { file, segments } of extracted) { + for (let index = 0; index < entries.length; index += 1) { + const { file, segments } = entries[index]; + const queueItem = queueItems[index]; + if (queueItem) { + const patch = { status: 'uploading', error: null, code: null }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } // eslint-disable-next-line no-await-in-loop const destinationId = segments.length ? await ensureFolderPathOnServer(baseFolderId, segments) @@ -238,8 +315,34 @@ const useDocumentUploads = ({ destinationId ?? (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, uploadTarget); + try { + // eslint-disable-next-line no-await-in-loop + const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile( + file, + uploadTarget, + ); + if (queueItem) { + const patch = { + status: duplicate ? 'duplicate' : 'success', + code: statusCode ?? null, + document: document || queueItem.document, + conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId, + }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } + } catch (error) { + if (queueItem) { + const patch = { + status: 'error', + error: error.response?.data?.error || error.message || 'Upload failed.', + code: error.response?.status ?? null, + }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } + continue; + } } await refreshCurrentFolder(); @@ -252,25 +355,60 @@ const useDocumentUploads = ({ await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { - notifyApiError(error, 'Failed to upload files.'); + const message = error.message || 'Failed to upload files.'; + queueItems.forEach((item) => { + if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') { + return; + } + const patch = { + status: 'error', + error: message, + code: error.response?.status ?? null, + }; + updateQueueItem(item.id, patch); + Object.assign(item, patch); + }); + console.error('[Uploads] batch failed', error); } finally { setLoading(false); } }, [ token, - extractFilesFromDataTransfer, ensureFolderPathOnServer, uploadFile, refreshCurrentFolder, selectedFolder, ensureFolderData, - notifyApiError, - setStatusMessage, setLoading, + appendQueueItems, + updateQueueItem, ], ); + const handleFileDrop = useCallback( + async (dataTransfer, targetFolderId) => { + let extracted; + try { + extracted = await extractFilesFromDataTransfer(dataTransfer); + } catch (error) { + console.error('[Uploads] Failed to process dropped files.', error); + return; + } + + await uploadFileEntries(extracted, targetFolderId); + }, + [extractFilesFromDataTransfer, uploadFileEntries], + ); + + const handleFileSelection = useCallback( + async (files, targetFolderId) => { + const entries = mapFilesToEntries(files); + await uploadFileEntries(entries, targetFolderId); + }, + [uploadFileEntries], + ); + useFileDrop({ shellRef, token, @@ -287,6 +425,11 @@ const useDocumentUploads = ({ const resetUploadsState = useCallback(() => { dragCounterRef.current = 0; setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); + setUploadQueue([]); + }, []); + + const clearUploadQueue = useCallback(() => { + setUploadQueue([]); }, []); return { @@ -294,9 +437,12 @@ const useDocumentUploads = ({ setDropOverlayState, dragCounterRef, handleFileDrop, + handleFileSelection, uploadFile, extractFilesFromDataTransfer, resetUploadsState, + uploadQueue, + clearUploadQueue, }; }; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index 28eb0cb..f79dc09 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -12,7 +12,7 @@ import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { api, useAppDispatch, useAppState } from '../../app/appState'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; -import { useEntryPointerHandler as useEntryPointerCore } from '../../documents/useEntryPointer'; +import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection'; import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions'; @@ -65,8 +65,6 @@ const useDocumentsWorkspace = ({ onToggleSearchIncludeDescendants, onSetSearchIncludeDescendants, sortRefreshReadyRef, - deskHelpOpen = false, - setDeskHelpOpen, handleDeskExit, } = {}) => { const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; @@ -74,7 +72,6 @@ const useDocumentsWorkspace = ({ const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; const toggleSearchIncludeDescendants = onToggleSearchIncludeDescendants || noop; const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop; - const setDeskHelpOpenSafe = setDeskHelpOpen || noop; const handleDeskExitSafe = handleDeskExit || noop; const fallbackSortFieldRef = useRef(documentsSortField); @@ -533,6 +530,9 @@ const useDocumentsWorkspace = ({ const { dropOverlayState, handleFileDrop, + handleFileSelection, + uploadQueue, + clearUploadQueue, resetUploadsState, } = useDocumentUploads({ apiClient: api, @@ -623,6 +623,7 @@ const useDocumentsWorkspace = ({ assetManager.reset(); resetPreviewState(); resetUploadsState(); + clearUploadQueue(); breadcrumbFetchRef.current = new Set(); detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; @@ -653,6 +654,7 @@ const useDocumentsWorkspace = ({ setActivePreviewId, resetPreviewState, resetUploadsState, + clearUploadQueue, ]); useEffect(() => { @@ -1022,9 +1024,27 @@ const useDocumentsWorkspace = ({ setStatusMessage, }); + const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => { - navigate('/settings'); - }, [navigate]); + setSettingsOpen(true); + }, []); + const closeSettings = useCallback(() => { + setSettingsOpen(false); + }, []); + + useEffect(() => { + if (!settingsOpen) { + return undefined; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + setSettingsOpen(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [settingsOpen]); useEffect( () => () => { @@ -1371,6 +1391,8 @@ const useDocumentsWorkspace = ({ currentTenantId, handleTenantSelect, openSettings, + handleFileSelection, + uploadQueue, }); @@ -1421,8 +1443,6 @@ const useDocumentsWorkspace = ({ searchQuery, activeCorrespondentFilters, selectedFolder, - setDeskHelpOpen: setDeskHelpOpenSafe, - deskHelpOpen, openDetailPanel, }); @@ -1476,6 +1496,8 @@ const useDocumentsWorkspace = ({ openSettings, detailPanelOpen, openDetailPanel, + uploadQueue, + clearUploadQueue, }), [ token, @@ -1526,6 +1548,8 @@ const useDocumentsWorkspace = ({ openSettings, detailPanelOpen, openDetailPanel, + uploadQueue, + clearUploadQueue, ], ); @@ -1537,6 +1561,8 @@ const useDocumentsWorkspace = ({ dropOverlayState, managementModals, contextValue, + settingsOpen, + closeSettings, }; }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 78a38a0..4e334ad 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -3,7 +3,7 @@ import '@fontsource/inter/400.css'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { HashRouter } from 'react-router-dom'; -import './styles.css'; +import './styles/index.css'; import { AppStateProvider } from './app/appState'; import AppRouter from './app/AppRouter'; diff --git a/frontend/src/settings/sections/ApiTokensSection.jsx b/frontend/src/settings/sections/ApiTokensSection.jsx index fd65511..b3ea68c 100644 --- a/frontend/src/settings/sections/ApiTokensSection.jsx +++ b/frontend/src/settings/sections/ApiTokensSection.jsx @@ -365,9 +365,6 @@ const ApiTokensSection = ({ const selectedSet = token?.capability_set_id ? capabilitySetMap[token.capability_set_id] : null; - const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length - ? token.capabilities - : selectedSet?.capabilities || []; const capabilitySetLabel = selectedSet?.label || selectedSet?.slug || token.capability_set_id @@ -386,15 +383,6 @@ const ApiTokensSection = ({ {capabilitySetsLoading ? ( Loading capability sets… ) : null} - {!capabilitySetsLoading && capabilityList.length ? ( -
- {capabilityList.map((value) => ( - - {formatCapabilityLabel(value)} - - ))} -
- ) : null} {isRevoked ? ( diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index ac576af..84b084c 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -16,6 +16,7 @@ import { SunIcon, MoonIcon, DesktopIcon, + UploadIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; import useFloatingMenu from '../ui/useFloatingMenu'; @@ -187,6 +188,7 @@ const Sidebar = ({ activeTenantId = null, onSelectTenant, onOpenSettings, + onUploadFiles, }) => { const { setCollapsed, @@ -197,6 +199,7 @@ const Sidebar = ({ cycleThemeMode, themeModes, } = useSidebarContext(); + const uploadInputRef = useRef(null); const handleCollapse = useCallback(() => { setCollapsed(true); }, [setCollapsed]); @@ -258,6 +261,26 @@ const Sidebar = ({ onCreateFolder?.(); }, [creatingFolder, onCreateFolder]); + const handleUploadButtonClick = useCallback(() => { + if (!onUploadFiles || !uploadInputRef.current) { + return; + } + uploadInputRef.current.click(); + }, [onUploadFiles]); + + const handleUploadInputChange = useCallback( + (event) => { + const files = event.target?.files; + if (files && files.length && onUploadFiles) { + onUploadFiles(files, selectedFolder); + } + if (event.target) { + event.target.value = ''; + } + }, + [onUploadFiles, selectedFolder], + ); + const handleNeutralHueReset = useCallback(() => { resetNeutralHue(); }, [resetNeutralHue]); @@ -294,6 +317,50 @@ const Sidebar = ({ cycleThemeMode(); }, [cycleThemeMode]); + const themeMenuSection = + typeof neutralHue === 'number' || typeof neutralHue === 'string' + ? ( +
+
+ Theme +
+ + +
+
+ +
+ ) + : null; + const handleSearchInputChange = useCallback( (event) => { onSearchChange?.(event.target.value); @@ -324,6 +391,7 @@ const Sidebar = ({ offset: 6, }); const showTenantList = tenants.length > 1; + const menuClassName = `menu${!showTenantList && !themeMenuSection ? ' menu--simple' : ''}`; const toggleTenantMenu = useCallback(() => { if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) { @@ -402,52 +470,15 @@ const Sidebar = ({ ); const rootNode = folderNodes.get('root'); - const themeControls = - typeof neutralHue === 'number' || typeof neutralHue === 'string' - ? ( -
-
-

Theme

-
- - -
-
- -
- ) - : null; - return (
+ {themeMenuSection} , document.body, ) : null} )} - actions={ - handleCollapse - ? [ - ( - - ), - ] - : null - } + actions={( + <> + {handleCollapse ? ( + + ) : null} + + + )} />
{status && ( @@ -722,7 +761,6 @@ const Sidebar = ({
- {themeControls ?
{themeControls}
: null} ); }; diff --git a/frontend/src/sidebar/useSidebarProps.js b/frontend/src/sidebar/useSidebarProps.js index dd38f75..d305b18 100644 --- a/frontend/src/sidebar/useSidebarProps.js +++ b/frontend/src/sidebar/useSidebarProps.js @@ -35,6 +35,8 @@ const useSidebarProps = ({ currentTenantId, handleTenantSelect, openSettings, + handleFileSelection, + uploadQueue, }) => useMemo( () => ({ @@ -76,8 +78,21 @@ const useSidebarProps = ({ activeTenantId: currentTenantId, onSelectTenant: handleTenantSelect, onOpenSettings: openSettings, + onUploadFiles: (files, targetFolderId) => { + if (!handleFileSelection) { + return; + } + if (!files || files.length === 0) { + return; + } + const folderId = targetFolderId ?? selectedFolder ?? 'root'; + handleFileSelection(files, folderId); + }, + uploadQueue, }), [ + handleFileSelection, + uploadQueue, activeCorrespondentFilters, activeTagFilters, appStatus, diff --git a/frontend/src/styles.css b/frontend/src/styles.css deleted file mode 100644 index 5687227..0000000 --- a/frontend/src/styles.css +++ /dev/null @@ -1,4273 +0,0 @@ -:root { - color-scheme: light dark; - - --neutral-hue: 180deg; - --foreground-hue-offset: 10deg; - --accent-hue-offset: -20deg; - --dark-hue-offset: 30deg; - --dark-foreground-hue-offset: 10deg; - - /* --- Neutrals --- */ - --bg: oklch(0.97 0.001 var(--neutral-hue)); - --surface: oklch(0.985 0.001 var(--neutral-hue)); - --surface-subtle: oklch(0.96 0.002 var(--neutral-hue)); - --fg: oklch(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --muted: oklch(0.54 0.008 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --sidebar-fg: oklch(0.56 0.007 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --border: oklch(0.92 0.002 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - - /* --- Accent (primary) --- */ - --accent: oklch(0.61 0.2 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); - --accent-hover: oklch(0.70 0.02 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); - --on-accent: oklch(1 0 0); - - --folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%); - --folder-icon-mid: color-mix(in oklch, var(--accent) 60%, white 30%); - --folder-icon-front: color-mix(in oklch, var(--accent) 42%, white 58%); - - --success-hue: 140deg; - --warning-hue: 85deg; - --danger-hue: 25deg; - - --accent-soft: color-mix(in oklch, var(--accent) 12%, transparent); - --accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent); - --accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent); - --accent-outline: color-mix(in oklch, var(--accent) 45%, transparent); - --accent-outline-strong: color-mix(in oklch, var(--accent) 90%, transparent); - --accent-focus: color-mix(in oklch, var(--accent) 85%, transparent); - --surface-overlay: color-mix(in oklch, white 82%, transparent); - - /* -- Selection --- */ - --selection: oklch(0.61 0.01 var(--neutral-hue)); - - /* --- Derived accent states --- */ - --selection-soft: color-mix(in oklch, var(--selection) 12%, transparent); - - /* --- Shadows & overlays --- */ - --shadow-faint: color-mix(in oklch, black 6%, transparent); - --shadow-soft: color-mix(in oklch, black 12%, transparent); - --shadow-medium: color-mix(in oklch, black 18%, transparent); - --shadow-strong: color-mix(in oklch, black 28%, transparent); - --shadow-deep: color-mix(in oklch, black 55%, transparent); - --shadow-pop: color-mix(in oklch, black 25%, transparent); - --outline-subtle: color-mix(in oklch, black 6%, transparent); - --overlay-dark: color-mix(in oklch, black 55%, transparent); - --overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 20%, transparent); - --overlay-darker: color-mix(in oklch, black 75%, transparent); - --surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent); - --overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent); - --border-strong: color-mix(in oklch, var(--fg) 24%, transparent); - --surface-hover: color-mix(in oklch, white 10%, transparent); - --overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent); - --overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 45%, transparent); - --overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 18%, transparent); - - /* --- Semantic colors --- */ - --success: oklch(0.68 0.16 var(--success-hue)); - --warning: oklch(0.76 0.17 var(--warning-hue)); - --danger: oklch(0.64 0.2 var(--danger-hue)); - - --success-subtle: color-mix(in oklch, var(--success) 12%, transparent); - --warning-subtle: color-mix(in oklch, var(--warning) 12%, transparent); - --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); - --danger-soft: color-mix(in oklch, var(--danger) 12%, transparent); - --danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent); - - /* --- Explorer states --- */ - --row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent); - --row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent); - --sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent); - --sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent); - --selection-ring: oklch(0.78 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent); - --link: var(--accent); - --link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); - --preview-nav-bg: oklch(0.18 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --preview-nav-bg-hover: oklch(0.14 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --preview-nav-fg: var(--on-accent); - --text-on-dark: color-mix(in oklch, white 90%, transparent); - --surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent); - - font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 15px; - --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - - --detail-panel-width: 30em; - --documents-grid-title-size: 0.8rem; -} - -:root[data-theme='light'] { - color-scheme: light; -} - -:root[data-theme='dark'] { - color-scheme: dark; - - --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); - --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0.01 var(--dark-neutral-hue)); - --surface: oklch(0.19 0.012 var(--dark-neutral-hue)); - --surface-subtle: oklch(0.23 0.012 var(--dark-neutral-hue)); - --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); - --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); - --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); - --border: oklch(0.33 0.01 var(--dark-foreground-hue)); - - --accent: oklch(0.75 0.2 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); - - --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); - --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); - --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); - - --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); - --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); - --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); - --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); - --accent-outline-strong: color-mix(in oklch, var(--accent) 90%, transparent); - --accent-focus: color-mix(in oklch, var(--accent) 80%, transparent); - --surface-overlay: color-mix(in oklch, black 60%, transparent); - - --selection: oklch(0.44 0.04 220deg); - --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); - - --shadow-faint: color-mix(in oklch, black 35%, transparent); - --shadow-soft: color-mix(in oklch, black 50%, transparent); - --shadow-medium: color-mix(in oklch, black 65%, transparent); - --shadow-strong: color-mix(in oklch, black 80%, transparent); - --shadow-deep: color-mix(in oklch, black 90%, transparent); - --shadow-pop: color-mix(in oklch, black 70%, transparent); - --outline-subtle: color-mix(in oklch, white 10%, transparent); - --overlay-dark: color-mix(in oklch, black 70%, transparent); - --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); - --overlay-darker: color-mix(in oklch, black 85%, transparent); - --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); - --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); - --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); - --surface-hover: color-mix(in oklch, white 6%, transparent); - --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); - --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); - --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); - - --success: oklch(0.62 0.16 var(--success-hue)); - --warning: oklch(0.68 0.17 var(--warning-hue)); - --danger: oklch(0.60 0.2 var(--danger-hue)); - - --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); - --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); - --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); - --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); - --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); - - --row-hover-bg: color-mix(in oklch, white 4%, transparent); - --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); - --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); - --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); - --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); - --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); - --link: color-mix(in oklch, var(--accent) 92%, transparent); - --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); - --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); - --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); - --preview-nav-fg: var(--fg); - --text-on-dark: color-mix(in oklch, white 92%, transparent); - --surface-ink-soft: color-mix(in oklch, white 12%, transparent); -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) { - color-scheme: dark; - - --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); - --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0.01 var(--dark-neutral-hue)); - --surface: oklch(0.19 0.012 var(--dark-neutral-hue)); - --surface-subtle: oklch(0.23 0.012 var(--dark-neutral-hue)); - --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); - --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); - --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); - --border: oklch(0.33 0.01 var(--dark-foreground-hue)); - - --accent: oklch(0.75 0.2 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); - - --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); - --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); - --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); - - --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); - --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); - --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); - --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); - --accent-outline-strong: color-mix(in oklch, var(--accent) 90%, transparent); - --accent-focus: color-mix(in oklch, var(--accent) 80%, transparent); - --surface-overlay: color-mix(in oklch, black 60%, transparent); - - --selection: oklch(0.44 0.04 220deg); - --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); - - --shadow-faint: color-mix(in oklch, black 35%, transparent); - --shadow-soft: color-mix(in oklch, black 50%, transparent); - --shadow-medium: color-mix(in oklch, black 65%, transparent); - --shadow-strong: color-mix(in oklch, black 80%, transparent); - --shadow-deep: color-mix(in oklch, black 90%, transparent); - --shadow-pop: color-mix(in oklch, black 70%, transparent); - --outline-subtle: color-mix(in oklch, white 10%, transparent); - --overlay-dark: color-mix(in oklch, black 70%, transparent); - --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); - --overlay-darker: color-mix(in oklch, black 85%, transparent); - --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); - --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); - --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); - --surface-hover: color-mix(in oklch, white 6%, transparent); - --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); - --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); - --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); - - --success: oklch(0.62 0.16 var(--success-hue)); - --warning: oklch(0.68 0.17 var(--warning-hue)); - --danger: oklch(0.60 0.2 var(--danger-hue)); - - --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); - --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); - --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); - --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); - --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); - - --row-hover-bg: color-mix(in oklch, white 4%, transparent); - --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); - --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); - --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); - --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); - --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); - --link: color-mix(in oklch, var(--accent) 92%, transparent); - --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); - --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); - --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); - --preview-nav-fg: var(--fg); - --text-on-dark: color-mix(in oklch, white 92%, transparent); - --surface-ink-soft: color-mix(in oklch, white 12%, transparent); - } -} - -html, -body { - height: 100%; -} - -body { - margin: 0; - background: var(--bg); - color: var(--fg); - overflow: hidden; -} - -body.has-main-content { - background: var(--surface); -} - -a { - color: var(--accent); - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 0.18em; - transition: color 0.15s ease, text-decoration-color 0.15s ease; -} - -a:visited { - color: var(--link-visited); -} - -a:hover, -a:focus-visible { - color: var(--accent-hover); - outline: none; - text-decoration-color: currentColor; -} - -#app { - height: 100%; -} - -button { - font: inherit; - border-radius: 2px; - border: 1px solid transparent; - padding: 0.35rem 0.85rem; - background: var(--accent); - color: var(--on-accent); - cursor: pointer; - font-weight: 500; - transition: background 0.15s ease, border-color 0.15s ease; -} - -button[disabled] { - opacity: 0.55; - cursor: not-allowed; -} - -a.button-link { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font: inherit; - border-radius: 2px; - border: 1px solid transparent; - padding: 0.35rem 0.85rem; - background: var(--accent); - color: var(--on-accent); - text-decoration: none; - font-weight: 500; - transition: background 0.15s ease, border-color 0.15s ease; -} - -a.button-link[aria-disabled='true'] { - opacity: 0.55; -} - -a.button-link:hover:not([aria-disabled='true']) { - background: var(--accent-hover); -} - -button.secondary { - background: transparent; - color: var(--fg); - border-color: var(--border); -} - -button.secondary:hover:not([disabled]) { - background: var(--surface-subtle); -} - -button.danger { - background: transparent; - color: var(--danger); - border-color: var(--danger-border); -} - -button.danger:hover:not([disabled]) { - border-color: var(--danger); - background: var(--danger-subtle); -} - -.icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--muted); - padding: 0.25rem; - border-radius: 4px; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; -} - -.icon-button:hover:not([disabled]) { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.icon-button.danger { - color: var(--danger); -} - -.icon-button.danger:hover:not([disabled]) { - background: var(--danger-subtle); -} - -.icon-button.ghost { - border: none; - background: transparent; - color: var(--muted); - padding: 0.2rem; -} - -.icon-button.ghost:hover:not([disabled]) { - color: var(--danger); - background: transparent; -} - -.panel-header .icon-button, -.panel-header button, -.panel-header a.icon-button { - display: inline-flex; - align-items: center; - justify-content: flex-start; - border: none; - background: transparent; - color: var(--muted); - padding: 0.25rem; - border-radius: 4px; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; - text-decoration: none; - text-align: left; -} - -.panel-header .icon-button:hover:not([disabled]), -.panel-header button:hover:not([disabled]), -.panel-header a.icon-button:hover { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.panel-header .icon-button.active:hover:not([disabled]), -.panel-header button.active:hover:not([disabled]), -.panel-header a.icon-button.active:hover { - background: var(--accent-soft); - color: var(--fg); -} - -.panel-header .icon-button.ghost, -.panel-header button.icon-button.ghost { - color: var(--muted); - padding: 0.25rem; -} - -.panel-header .icon-button.ghost:hover:not([disabled]), -.panel-header button.icon-button.ghost:hover:not([disabled]) { - color: var(--fg); - background: var(--sidebar-hover-bg); -} - -.preview-zoom-backdrop { - position: fixed; - inset: 0; - background: var(--overlay-backdrop); - transition: background 0.25s ease, opacity 0.25s ease; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; - z-index: 2000000; - cursor: zoom-out; - opacity: 0; - pointer-events: none; -} - -.preview-zoom-backdrop--visible { - opacity: 1; - background: var(--overlay-backdrop); - pointer-events: auto; -} - -.preview-zoom__stage { - position: relative; - display: flex; - align-items: center; - justify-content: center; - max-width: 95vw; - max-height: 95vh; - z-index: 3000000; -} - -.preview-zoom__image { - max-width: 95vw; - max-height: 95vh; - width: auto; - height: auto; -} - -.preview-zoom__scroll { - display: flex; - align-items: center; - justify-content: center; - max-width: 95vw; - max-height: 95vh; - box-shadow: 0 32px 120px var(--shadow-deep); -} - -.preview-zoom__scroll:focus { - outline: none; -} - -.preview-zoom__scroll--native { - overflow: auto; - cursor: zoom-out; - justify-content: flex-start; - align-items: flex-start; -} - -.preview-zoom__nav { - position: absolute; - bottom: 1em; - left: 50%; - transform: translateX(-50%); - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.9rem; - opacity: 0; - transition: opacity 0.18s ease; - pointer-events: none; -} - -.preview-zoom__stage:hover .preview-zoom__nav, -.preview-zoom__stage:focus-within .preview-zoom__nav { - opacity: 1; - pointer-events: auto; -} - -.preview-zoom__nav-button { - width: 2.8rem; - height: 2.8rem; - border: none; - border-radius: 999px; - background: var(--preview-nav-bg); - color: var(--preview-nav-fg); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 1; - transition: background 0.15s ease, transform 0.15s ease; - box-shadow: 0 12px 28px var(--shadow-strong); -} - -.preview-zoom__nav-button:hover { - background: var(--preview-nav-bg-hover); -} - -.preview-zoom__nav-button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -.preview-zoom__nav-button svg { - width: 1.5rem; - height: 1.5rem; -} - -.preview-zoom__nav-button[disabled] { - opacity: 0.35; - cursor: default; -} - -.with-icon { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.icon-inline { - width: 1rem; - height: 1rem; -} - -.icon { - width: 1rem; - height: 1rem; - display: inline-flex; - align-items: center; - justify-content: center; -} - -@keyframes icon-spin { - to { - transform: rotate(360deg); - } -} - -.icon--spin { - animation: icon-spin 0.9s linear infinite; -} - -.icon--flip-y { - transform: scaleX(-1); -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} - -.icon path { - stroke: currentColor; - stroke-width: 1.6; - fill: none; -} - -.icon--fill path { - stroke: none; - fill: currentColor; -} - - -.view-toggle { - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.view-toggle__button { - border: 1px solid var(--border); - background: transparent; - color: var(--muted); - padding: 0.3rem 0.6rem; - border-radius: 4px; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; -} - -.view-toggle__button:hover, -.view-toggle__button:focus-visible { - background: var(--surface-subtle); - color: var(--fg); - outline: none; -} - -.view-toggle__button.active { - background: var(--accent); - color: var(--on-accent); - border-color: var(--accent); -} - -.view-toggle__icon { - width: 1.4rem; - height: 1.4rem; -} - -.documents-actions__sort-group { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.documents-sort { - display: inline-flex; - align-items: center; - position: relative; -} - -.documents-sort__trigger { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.85rem; - white-space: nowrap; - padding: 0.25rem 0.5rem; - min-height: 2.1rem; -} - -.documents-sort__label { - display: inline-flex; - align-items: center; - line-height: 1.1; -} - -.documents-sort__trigger-content { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.documents-sort__quickmenu .menu__item, -.documents-sort__quickmenu .menu__item.active { - font-weight: 400; -} - -.documents-toolbar__toggle { - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.3rem; - background: transparent; - color: var(--muted); - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; -} - -.documents-toolbar__toggle:hover:not([disabled]) { - color: var(--fg); - border-color: var(--border); -} - -.documents-toolbar__toggle[aria-pressed='true'] { - border-color: var(--accent); - color: var(--accent); - background: var(--surface-subtle); -} - -.documents-sort__direction { - padding: 0.3rem 0.45rem; -} - -.documents-sort__direction[aria-pressed='true'] { - border-color: var(--border); - color: var(--muted); - background: transparent; -} - -.documents-sort__direction svg { - width: 1.1rem; - height: 1.1rem; -} - -.app-shell { - height: 100%; - display: flex; - flex-direction: column; - color: var(--fg); -} - - -.documents-main { - flex: 1; - display: flex; - min-height: 100vh; - width: 100%; - position: relative; -} - -.documents-main--sidebar-collapsed { - position: relative; -} - -.main-content { - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; - margin: 0; - border-radius: 0; - background: var(--surface); - flex-grow: 1; - box-shadow: -12px 0 24px -12px var(--shadow-faint); -} - -.documents-main:not(.documents-main--sidebar-collapsed) .main-content { - border-left: 1px solid var(--border); -} - -.main-content__header { - padding: 0.5rem 1.25rem; - justify-content: space-between; - align-items: center; - gap: 0.75rem; -} - -.main-content__header-wrapper { - position: relative; -} - -.main-content__body { - position: relative; - flex: 1 1 auto; - display: flex; - min-height: 0; -} - -.main-content__body > * { - min-width: 0; - min-height: 0; -} - -.main-content__body > *:not(.detail-panel) { - flex: 1 1 auto; -} - -.main-content__body--workspace.main-content__body--has-detail > .desk-shell { - margin-right: calc(-1 * var(--detail-panel-width)); -} - -.main-content--has-detail { - padding-right: var(--detail-panel-width); -} - -.main-content__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--fg); -} - - -.documents-main--sidebar-collapsed { - position: relative; - grid-template-columns: auto minmax(0, 1fr); -} - -.sidebar .panel-header .icon, -.main-content__header .icon, -.detail-panel .panel-header .icon { - width: 1.35rem; - height: 1.35rem; -} - -.main-content__actions { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.main-content__actions-divider { - display: inline-flex; - align-items: center; - color: var(--muted); -} - -.main-content__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--fg); - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 0; - flex: 1 1 auto; - overflow: hidden; -} - -.main-content__breadcrumbs { - flex: 1 1 auto; - overflow: hidden; -} - -.main-content__subtitle { - font-size: 0.9rem; - color: var(--muted); - font-weight: 400; - line-height: 1.2; - flex-shrink: 0; -} - -.panels-main { - flex: 1; - display: flex; - justify-content: center; - padding: 1.5rem; - overflow: auto; -} - -.panels-main > * { - flex: 0 1 720px; -} - -.preview-main { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.document-viewer { - flex: 1; - display: grid; - grid-template-columns: minmax(0, 30em) minmax(0, 1fr); - gap: 1rem; - min-height: 0; - padding: 1rem 1rem; -} - -.document-drag-preview { - position: fixed; - pointer-events: none; - top: -9999px; - left: -9999px; - width: var(--drag-preview-size, 96px); - height: var(--drag-preview-size, 96px); - z-index: 9999; -} - -.document-drag-preview__thumb { - position: absolute; - top: 50%; - left: 50%; - width: 64px; - height: 64px; - border-radius: 6px; - box-shadow: 0 6px 12px var(--shadow-pop); - overflow: hidden; - background-color: var(--overlay-dim); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 600; - font-size: 0.8rem; - text-transform: uppercase; - transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg)); - transform-origin: center; -} - -.document-drag-preview__thumb--image { - background-color: #000; - background-repeat: no-repeat; - background-size: contain; - background-position: center; -} - -.document-drag-preview__thumb .document-thumbnail, -.document-drag-preview__thumb img { - width: 100%; - height: 100%; - object-fit: cover; - pointer-events: none; -} - -.document-drag-preview__thumb .thumb-placeholder, -.document-drag-preview__thumb .thumb-placeholder * { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.document-drag-preview__folder-thumb { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.document-drag-preview__folder-thumb svg { - width: 48px; - height: 48px; - color: var(--accent-strong, var(--accent)); -} - -.document-drag-preview__folder-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-on-dark); -} - -.document-drag-preview__count { - position: absolute; - bottom: 4px; - right: 4px; - background-color: var(--accent); - color: var(--on-accent); - border-radius: 999px; - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 600; - box-shadow: 0 4px 8px var(--shadow-pop); - pointer-events: none; -} - -.document-viewer__details { - display: flex; - flex-direction: column; - gap: 0.5rem; - min-height: 0; - flex: 1; -} - -.document-viewer__details-pane { - display: flex; - flex-direction: column; - min-height: 0; - overflow: auto; - flex: 1; -} -.document-viewer__tabs-wrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; -} - -.document-viewer__section { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - padding-top: 1rem; -} - -.document-viewer__section-title { - margin: 0; - font-size: 0.95rem; - font-weight: 600; - letter-spacing: 0.01em; -} - -.document-viewer__section-list { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.75rem 1.25rem; -} - -.document-viewer__section-item { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.document-viewer__section-item dt { - font-size: 0.75rem; - color: var(--muted); -} - -.document-viewer__section-item dd { - margin: 0; - font-size: 0.9rem; - font-weight: 500; - word-break: break-word; -} - -.document-viewer__section-placeholder { - margin: 0; - font-size: 0.9rem; - color: var(--muted); -} - -.document-viewer__section-payload { - font-size: 0.85rem; -} - -.document-viewer__section-payload summary { - cursor: pointer; - font-weight: 500; - color: var(--accent-strong, var(--accent)); -} - -.document-viewer__section-payload pre { - margin: 0.75rem 0 0; - padding: 0.75rem; - background: var(--surface); - max-height: 280px; - overflow: auto; - font-size: 0.8rem; -} - -.document-viewer__section--metadata-json { - overflow: auto; -} - -.document-viewer__metadata-json { - margin: 0; - padding: 0.75rem; - background: var(--surface); - border-radius: 0.5rem; - font-size: 0.85rem; - line-height: 1.35; - overflow: auto; -} - -.document-viewer__tabs { - display: inline-flex; - align-items: center; - gap: 0.5rem; - border-bottom: 1px solid var(--outline-subtle); -} - -.document-viewer__tab { - appearance: none; - border: none; - background: transparent; - padding: 0.4rem 0.75rem; - font-size: 0.85rem; - font-weight: 500; - color: var(--muted); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: - color 120ms ease, - border-color 120ms ease; -} - -.document-viewer__tab:hover, -.document-viewer__tab:focus-visible { - color: var(--accent); -} - -.document-viewer__tab.is-active { - color: var(--accent); - border-color: var(--accent); -} - -.document-viewer__tabpanes { - flex: 1; - min-height: 0; - display: flex; -} - -.document-viewer__tabpanes--single { - flex: 1; - min-height: 0; -} - -.document-viewer__tabpanel { - flex: 1; - min-height: 0; - display: flex; - position: relative; -} - -.document-viewer__object--ocr { - width: 100%; - height: 100%; -} - -.document-viewer__object--ocr-text { - width: 100%; - height: 100%; - margin: 0; - padding: 1rem 0; - font-size: 1rem; - white-space: pre-wrap; - font-family: inherit; -} - -.document-viewer__message--error { - color: var(--danger); -} - -.document-viewer__viewport { - flex: 1; - min-width: 0; - display: flex; - position: relative; - overflow: hidden; - align-items: flex-start; -} - -.document-viewer__object { - width: 100%; - height: 100%; - border: none; -} - -.document-viewer__object--image { - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; - object-fit: contain; - align-self: flex-start; -} - -.document-viewer__unsupported { - height: 100%; - width: 100%; - padding: 2rem; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.75rem; - text-align: center; - background: var(--surface-subtle); -} - -.document-viewer__unsupported-filename { - font-size: 0.9rem; - color: var(--muted); -} - -.document-viewer__unsupported-message { - font-size: 0.95rem; - color: var(--fg); -} - -.document-viewer__unsupported-download { - font-weight: 600; -} - -.document-viewer__unsupported-download svg { - width: 1rem; - height: 1rem; -} - -.tags-panel__body { - overflow-y: auto; -} - -.tags-table { - width: 100%; - overflow: auto; -} - -.tags-table table { - width: 100%; - border-collapse: collapse; - min-width: 320px; -} - -.tags-table th, -.tags-table td { - padding: 0.45rem 0.6rem; - text-align: left; - font-size: 0.85rem; -} - -.tags-table th.numeric, -.tags-table td.numeric { - text-align: right; -} - -.tags-table th.actions, -.tags-table td.actions { - text-align: right; - width: 0; -} - -.tags-table tbody tr:hover { - background: var(--surface-subtle); -} - -.tags-table tr.editing { - background: var(--sidebar-hover-bg); -} - -.tags-table__label { - max-width: 24rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.tags-table__swatch { - display: inline-block; - width: 1rem; - height: 1rem; - border-radius: 2px; - box-shadow: inset 0 0 0 1px var(--shadow-faint); -} - -.tags-panel__error { - margin: 0.5rem 0; - color: var(--danger); - font-size: 0.8rem; -} - -.tags-table__label-input { - width: 100%; -} - -.tags-table__color-editor { - display: flex; - align-items: center; - gap: 0.4rem; -} - -.tags-table__color-picker { - width: 2.25rem; - height: 2.25rem; - padding: 0; - background: none; - cursor: pointer; -} - -.tags-table__edit-controls { - display: flex; - justify-content: flex-start; - padding: 0.25rem; - gap: 0.4rem; -} - -.tags-table__row-actions { - display: flex; - justify-content: flex-start; - padding: 0.25rem; - gap: 0.4rem; -} - -.correspondents-panel .header-actions { - gap: 0.5rem; -} - -.tags-actions { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - align-items: center; -} - -.tags-actions__form { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4rem; -} - -.tags-actions__form input[type='text'] { - min-width: 14rem; -} - -.correspondents-actions__form { - display: flex; - gap: 0.4rem; -} - -.correspondents-actions__form input { - min-width: 14rem; -} - - - -.correspondent-pill { - display: inline-flex; - align-items: center; - gap: 0.35rem; - background: var(--surface-subtle); - border-radius: 999px; - padding: 0.15rem 0.5rem; - font-size: 0.9rem; - border: none; -} - -.correspondent-pill__label { - line-height: 1.2; -} - -.correspondent-pill__label strong { - font-size: 0.8rem; - text-transform: capitalize; - color: var(--muted); -} - -.correspondent-pill__remove { - border: none; - background: none; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--muted); - padding: 0; -} - -.correspondent-pill__remove:hover { - color: var(--danger); -} - -.correspondent-form { - display: flex; - gap: 0.4rem; - align-items: center; -} - -.correspondent-form input, -.correspondent-form select { - min-height: 32px; -} - -.document-viewer__message { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: var(--on-accent); - background: var(--overlay-dark); - padding: 0.5rem 1rem; - border-radius: 999px; - font-size: 0.9rem; -} - -.panel-section__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0 0 0.5rem; -} - -.panel-section__titles { - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.panel-section__header h2 { - margin: 0; - font-size: 0.9rem; - font-weight: 600; -} - -.documents-panel__title { - display: flex; - align-items: center; - gap: 0.35rem; - margin: 0; - font-size: 0.9rem; - font-weight: 600; - min-width: 0; - flex-wrap: nowrap; - overflow: hidden; -} - -.panel-section__subtitle { - margin-top: 0.25rem; - font-size: 0.78rem; - color: var(--muted); -} - -.breadcrumb-trail { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: nowrap; - min-width: 0; - flex: 1 1 auto; - max-width: 100%; - overflow: hidden; -} - -.breadcrumb-trail--measure { - position: absolute; - visibility: hidden; - pointer-events: none; - left: -9999px; - top: -9999px; - max-width: none; - overflow: visible; -} - -.breadcrumb-trail__link { - display: inline-flex; - align-items: center; - max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - border: none; - background: none; - padding: 0; - font: inherit; - color: inherit; - cursor: default; -} - -.breadcrumb-trail__link:not(.is-current) { - cursor: pointer; - color: var(--accent); -} - -.breadcrumb-trail__link:not(.is-current):hover, -.breadcrumb-trail__link:not(.is-current):focus-visible { - text-decoration: underline; -} - -.breadcrumb-trail__separator { - color: var(--muted); - margin: 0 0.2rem; -} - -.breadcrumb-trail__ellipsis { - position: relative; - display: inline-flex; -} - -.breadcrumb-trail__ellipsis-button { - cursor: pointer; -} - -.documents-panel__breadcrumbs { - flex: 1 1 auto; - min-width: 0; - max-width: 100%; - overflow: hidden; -} - -.documents-panel__breadcrumbs { - max-width: 22rem; -} - -.panel-section__body { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.panel-section__body--scrollable, -.panel-section__body.scrollable { - overflow-y: auto; -} - -.folder-tree { - list-style: none; - margin: 0; - padding: 0; -} - -.folder-node { - margin: 0; -} - -.folder-row { - display: flex; - align-items: center; - gap: 0.22rem; - padding: 0.32rem 0.48rem; - border-radius: 8px; - cursor: pointer; - color: inherit; - transition: background 0.12s ease, color 0.12s ease; - position: relative; - overflow: hidden; - user-select: none; - -webkit-user-select: none; -} - -.folder-row .name-wrap { - display: inline-flex; - align-items: center; - gap: 0.25rem; - flex: 1; - min-width: 0; -} - -.folder-row .name-wrap .name__label { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.folder-row .folder-icon-image { - flex-shrink: 0; -} - -.folder-row:hover { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.folder-row.active { - background: var(--sidebar-active-bg); - color: var(--fg); -} - -.folder-row .toggle { - width: 1.05rem; - display: inline-flex; - align-items: center; - justify-content: center; - user-select: none; - flex-shrink: 0; - transition: transform 0.18s ease; -} - -.folder-row .toggle.invisible { - visibility: hidden; -} - -.toggle-icon { - width: 0.9rem; - height: 0.9rem; - transition: transform 0.18s ease; -} - -.folder-row .toggle.expanded .toggle-icon { - transform: rotate(90deg); -} - -.folder-row__actions { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - display: flex; - gap: 0; - opacity: 0; - pointer-events: none; - transition: opacity 0.12s ease; - background-color: var(--surface-overlay); - border-radius: 8px; - padding: 0 0.18rem; -} - -.folder-row__actions .icon-button { - margin: 0; -} - -.folder-row:hover .folder-row__actions, -.folder-row:focus-within .folder-row__actions { - opacity: 1; - pointer-events: auto; -} - -.folder-children { - list-style: none; - margin: 0 0 0 0.45rem; - padding: 0; -} - - - -.sidebar-section { - margin-top: 1rem; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.sidebar { - gap: 0; - overflow: hidden; - color: var(--sidebar-fg); - display: flex; - flex-direction: column; - width: 20em; - max-width: 20em; - flex: 0 0 20em; - background: var(--bg); -} - -.sidebar__body { - flex: 1; - display: flex; - flex-direction: column; - padding: 0.5rem 1rem; - gap: 0.75rem; - overflow-y: auto; - min-height: 0; -} - -.sidebar__footer { - margin-top: auto; - padding: 0.75rem 1rem 1rem; - border-top: 1px solid var(--border); - background: var(--surface-subtle); - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.sidebar__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--fg); - padding-left: 0.25rem; -} - -.sidebar__tenant { - color: var(--muted); - font-weight: 500; -} - -.sidebar__header { - position: relative; -} - -.sidebar__title-button { - display: inline-flex; - align-items: center; - gap: 0.35rem; - border: none; - background: none; - color: inherit; - font: inherit; - cursor: pointer; - padding: 0; -} - -.sidebar__title-button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.sidebar__title-chevron { - transition: transform 0.2s ease; -} - -.sidebar__title-chevron.is-open { - transform: rotate(180deg); -} - -.menu { - position: absolute; - top: 100%; - left: 1em; - right: auto; - background: var(--surface); - border: 1px solid var(--border-muted, var(--border)); - border-radius: 0.5rem; - box-shadow: 0 12px 28px var(--shadow-strong); - min-width: 220px; - z-index: 20; - overflow: hidden; -} - -.menu__list { - max-height: 260px; - overflow-y: auto; - padding: 0.35rem 0; - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.menu__heading { - padding: 0.5rem 1rem 0.25rem; - font-size: 0.82rem; - font-weight: 600; - color: var(--muted); -} - -.menu button.menu__item, -.menu .menu__item { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 0.6rem; - width: 100%; - padding: 0.55rem 1rem; - border: none; - background: none; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; -} - -.menu button.menu__item:hover, -.menu button.menu__item:focus-visible, -.menu .menu__item:hover, -.menu .menu__item:focus-visible { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.menu button.menu__item.active, -.menu .menu__item.active { - font-weight: 600; - color: var(--accent); -} - -.menu button.menu__item:focus-visible, -.menu .menu__item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.menu__label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; -} - -.menu__check-slot { - width: 1rem; - display: flex; - align-items: center; - justify-content: center; - color: var(--accent); -} - -.menu__active-indicator { - font-size: 0.75rem; - color: var(--muted); -} - -.menu__empty { - display: block; - padding: 0.6rem 0.75rem; - color: var(--muted); - font-size: 0.85rem; -} - -.menu__footer { - border-top: 1px solid var(--border-muted, var(--border)); - padding: 0.35rem 0.5rem; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.menu--simple .menu__footer { - border-top: none; -} - -.menu__settings { - width: 100%; - display: inline-flex; - align-items: center; - gap: 0.4rem; - border: none; - background: none; - color: inherit; - font: inherit; - cursor: pointer; - padding: 0.4rem 0.5rem; - border-radius: 0.35rem; -} - -.menu__settings:hover, -.menu__settings:focus-visible { - background: var(--sidebar-hover-bg); -} - -.menu__logout { - width: 100%; - display: inline-flex; - align-items: center; - gap: 0.4rem; - border: none; - background: none; - color: var(--danger); - font: inherit; - cursor: pointer; - padding: 0.4rem 0.5rem; - border-radius: 0.35rem; -} - -.menu__logout:hover, -.menu__logout:focus-visible { - background: var(--surface-danger-subtle); -} - -.sidebar__search { - display: flex; - align-items: center; - gap: 0.4rem; - margin: 0 0 0.75rem; -} - -.sidebar__search input[type='search'] { - flex: 1; - padding: 0.4rem 0.6rem; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--fg); -} - -.sidebar__search button { - padding: 0.35rem 0.75rem; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: var(--accent); - font-size: 0.85rem; - cursor: pointer; -} - -.sidebar__search button:hover:not([disabled]) { - background: var(--sidebar-hover-bg); -} - -.sidebar__footer .sidebar-section { - margin-top: 0; -} - -.sidebar__footer .sidebar-section__actions { - gap: 0.4rem; -} - -.sidebar-section:first-of-type, -.sidebar-section--folders { - margin-top: 0; -} - -.sidebar-section--folders { - display: flex; - flex-direction: column; -} - -.sidebar-section__header { - display: flex; - align-items: center; - font-size: 0.75rem; - justify-content: space-between; - color: var(--muted); -} - -.sidebar-section__title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.4rem; - width: 100%; - background: transparent; - border: none; - border-radius: 6px; - padding: 0.25rem 0.35rem; - margin: -0.25rem -0.35rem; - color: inherit; - font: inherit; - cursor: pointer; - text-align: left; -} - -.sidebar-section__title:hover:not([disabled]), -.sidebar-section__title:focus-visible { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.sidebar-section__title:hover:not([disabled]) h3, -.sidebar-section__title:focus-visible h3 { - color: var(--fg); -} - -.sidebar-section__header h3 { - margin: 0; - font-weight: 600; -} - -.sidebar-section__actions { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.sidebar-section__actions .meta { - font-size: 0.75rem; - color: var(--muted); -} - -.sidebar-slider { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8rem; - color: var(--muted); -} - -.sidebar-slider input[type='range'] { - flex: 1; - accent-color: var(--accent); -} - -.sidebar-slider__value { - min-width: 3rem; - text-align: right; - color: var(--fg); - font-variant-numeric: tabular-nums; -} - -.sidebar-item { - background: transparent; - padding: 0.16rem 0.26rem; - text-align: left; - color: var(--sidebar-fg); - border-radius: 2px; - cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; - display: flex; - align-items: center; - gap: 0.38rem; - width: 100%; -} - -.sidebar-item:hover { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.sidebar-item.active { - background: var(--sidebar-active-bg); - color: var(--fg); - font-weight: 600; -} - -.sidebar-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.sidebar-item__icon { - width: 1rem; - height: 1rem; - flex-shrink: 0; - color: var(--accent); -} - -.sidebar-item.active .sidebar-item__icon { - color: currentColor; -} - -.sidebar-tag-cloud { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - padding: 0.25rem 0 0.1rem; -} - -.sidebar-tag-pill { - border-radius: 999px; - padding: 0.25rem 0.6rem; - font-size: 0.75rem; - font-weight: 600; - line-height: 1; - cursor: pointer; - background: var(--surface-soft); - color: var(--fg); - transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, - opacity 0.12s ease; - box-shadow: 0 1px 2px var(--shadow-faint); -} - -.sidebar-tag-pill:hover { - transform: none; - box-shadow: 0 2px 6px var(--shadow-pop); - border-color: var(--accent-soft); -} - -.sidebar-tag-pill:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.sidebar-tag-pill.active { - border-color: var(--sidebar-active-pill-border); - box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border); -} - -.sidebar-tag-pill--untagged { - border: 1px dashed var(--border); - background: var(--surface-subtle); - color: var(--muted); -} - -.sidebar-tag-pill--untagged.active { - color: var(--fg); -} - -.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) { - opacity: 0.45; -} - -.sidebar-correspondent-list { - list-style: none; - margin: 0.4rem 0 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.sidebar-correspondent-item { - display: block; - background: transparent; - border-radius: 4px; - padding: 0.35rem 0.5rem; - text-align: left; - color: var(--sidebar-fg); - cursor: pointer; - font-size: 0.85rem; - transition: background 0.15s ease, box-shadow 0.15s ease; -} - -.sidebar-correspondent-item:hover { - background: var(--sidebar-hover-bg); - color: var(--fg); -} - -.sidebar-correspondent-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.sidebar-correspondent-item.active { - background: var(--sidebar-active-bg); - color: var(--fg); -} - - -.documents-panel { - padding: 0 0.75rem 0.75rem 0.75rem; - display: flex; - flex-direction: column; - min-height: 0; -} - -.tags-panel, -.correspondents-panel { - padding: 1.25rem; - display: flex; - flex-direction: column; - min-height: 0; -} - -.folder-row.is-drop-target { - outline: 2px dashed var(--accent); - outline-offset: 2px; -} - -.documents-panel .panel-section__header { - display: flex; - align-items: center; - justify-content: space-between; - position: relative; -} - -.documents-panel .panel-section__header .header-actions { - display: flex; - gap: 0.5rem; -} - -.documents-panel__title { - display: flex; - align-items: center; - gap: 0.35rem; - margin: 0; - font-size: 0.9rem; - font-weight: 600; - min-width: 0; - white-space: nowrap; - overflow: hidden; -} - -.documents-panel__breadcrumbs { - flex: 1 1 auto; - overflow: hidden; -} - -.documents-panel .panel-section__body { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.panel-floating { - position: absolute; - top: calc(50% + 0.25rem); - left: 50%; - transform: translate(-50%, -50%); - background: color-mix(in oklch, var(--surface) 100%, transparent); - border: 1px solid color-mix(in oklch, var(--border) 95%, transparent); - padding: 0.45rem 0.85rem; - border-radius: 1rem; - font-size: 0.95rem; - font-weight: 400; - color: var(--fg); - box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent); - pointer-events: auto; - display: flex; - align-items: center; - justify-content: center; - flex-wrap: nowrap; - gap: 0.75rem; - z-index: 2000000; -} - -.panel-floating__label { - white-space: nowrap; - pointer-events: none; - font-size: 0.95rem; - color: var(--fg); -} - -.selection-summary { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -.selection-summary__token { - display: inline-flex; - align-items: center; - gap: 0.3rem; -} - -.selection-summary__count { - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.selection-summary__icon { - width: 1rem; - height: 1rem; -} - -.selection-summary--text { - font-weight: 600; -} - -.selection-summary__separator { - opacity: 0.45; -} - -.panel-floating-actions { - display: inline-flex; - align-items: center; - gap: 0.4rem; - flex-wrap: nowrap; - pointer-events: auto; -} - -.panel-floating-actions .quick-add { - pointer-events: auto; -} - -.panel-floating-actions .quick-add__trigger { - pointer-events: auto; -} - -.panel-floating-actions .quick-add__trigger[disabled] { - opacity: 0.45; - cursor: not-allowed; -} - -.panel-floating-actions__button { - display: inline-flex; - align-items: center; - gap: 0; - pointer-events: auto; - font-size: 1.35rem; -} - -.panel-floating-actions__button .icon-inline { - display: inline-flex; - width: 1.35rem; - height: 1.35rem; -} - -.documents-panel .documents-scroll { - overflow-y: auto; - background: transparent; - min-height: 0; - padding-right: 0.3rem; - flex-grow: 1; -} - -.documents-panel .documents-scroll:focus-visible { - outline: 2px solid var(--selection-ring); - outline-offset: 2px; - border-radius: 0.25rem; -} - -.documents-panel table { - max-width: 100%; - border-collapse: collapse; - font-size: 0.88rem; - margin-top: 0.4rem; -} - -.documents-panel thead th { - background-color: var(--surface); - position: sticky; - top: 0; - z-index: 1; - padding: 0.45rem 0.6rem; - color: var(--muted); -} - - -.documents-panel th, -.documents-panel td { - padding: 0.45rem 0.6rem; - text-align: left; -} - -.documents-panel--view-grid .documents-scroll { - padding: 0.35rem 0.5rem 1rem; -} - -.documents-panel th.thumb-column, -.documents-panel td.thumb-cell { - width: 54px; - text-align: center; -} - -.documents-panel--view-grid .document-thumbnail-wrapper { - width: var(--documents-grid-icon-size); - height: var(--documents-grid-icon-size); - padding: 8px; - border-radius: 10px; - display: flex; - justify-content: center; - align-items: center; -} - -.documents-panel--view-grid .folder-card__icon { - width: var(--documents-grid-icon-size); - height: var(--documents-grid-icon-size); - padding: 8px; - border-radius: 10px; - display: flex; - justify-content: center; - align-items: center; -} - -.document-thumbnail-inner { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.document-thumbnail-inner--multipage::after { - content: ''; - position: absolute; - top: 0; - right: 0; - width: 24px; - height: 24px; - background-image: url('./assets/papercorner.svg'); - background-repeat: no-repeat; - background-size: contain; - pointer-events: none; -} - -.documents-panel--view-grid .document-thumbnail-inner--multipage::after { - width: 28px; - height: 28px; -} - -.document-thumbnail { - max-width: 100%; - max-height: 100%; - box-shadow: 0 1px 6px var(--shadow-medium); -} - -.documents-panel--view-grid .document-thumbnail { - box-shadow: 0 1px 12px var(--shadow-medium); -} - -.thumb-placeholder { - width: 100%; - height: 100%; - border-radius: 1px; - background: var(--surface-subtle); - color: var(--muted); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.08em; - box-shadow: 0 1px 3px var(--shadow-medium); -} - -.documents-panel--view-grid .thumb-placeholder { - font-size: 1.1rem; - letter-spacing: 0.12em; - display: inline-flex; - width: 100%; - height: 100%; -} - -.thumb-icon { - width: 100%; - height: 100%; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.thumb-icon__image { - width: 32px; - height: 32px; - display: block; -} - -.documents-panel th.actions-column, -.documents-panel td.actions { - width: 1%; - white-space: nowrap; -} - -.documents-panel td.doc-list__name { - width: 100%; -} - -.doc-title-edit { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex-wrap: nowrap; -} - -.doc-title-edit input[type='text'] { - padding: 0.3rem 0.55rem; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--fg); - min-width: 8rem; -} - -.doc-title-edit input[type='text']:focus-visible { - outline: 2px solid var(--selection-ring); - outline-offset: 1px; -} - -.doc-title-edit .icon-button { - flex-shrink: 0; -} - -.documents-panel .doc-name__primary { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -.documents-panel .doc-name__primary-text { - overflow-wrap: anywhere; -} - - -.doc-entry { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.doc-entry--with-thumb .doc-entry__thumb { - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: center; -} - -.doc-entry__thumb .document-thumbnail-wrapper { - display: flex; - align-items: center; - justify-content: center; -} - -.doc-entry__main { - display: flex; - flex-direction: column; - gap: 0.35rem; - min-width: 0; -} - -.doc-entry__name { - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; -} - -.doc-entry__tags { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; -} - -.documents-panel td.actions .action-buttons { - display: inline-flex; - align-items: center; - gap: 0.3rem; -} - -.documents-panel tbody tr { - background: transparent; - transition: background 0.15s ease; -} - -.documents-panel tbody tr.folder { - cursor: pointer; -} - -.documents-panel tbody tr.folder.focused { - box-shadow: inset 2px 0 0 var(--accent-outline); - background: var(--sidebar-hover-bg); -} - -.documents-panel tbody tr.document { - cursor: pointer; -} - -.documents-panel tbody tr.document.selected, -.documents-panel tbody tr.folder.selected { - background: var(--selection-soft); - box-shadow: inset 2px 0 0 var(--accent-outline-strong); -} - -.documents-panel tbody tr.document, -.documents-panel tbody tr.document * { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; -} - -.documents-panel tbody tr.document.focused:not(.selected) { - box-shadow: inset 2px 0 0 var(--accent-outline); -} - -.doc-name { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.documents-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(var(--documents-grid-icon-size), 1fr)); - gap: 0.3rem 1.3rem; - padding: 0; -} - -.document-card { - padding: 0rem; - display: flex; - flex-direction: column; - gap: 0.3rem; - cursor: pointer; - align-items: center; - text-align: center; -} - -.document-card.selected .document-thumbnail-wrapper, -.folder-card.selected .folder-card__icon { - background-color: var(--selection-soft); -} - -.document-card:focus-visible .document-thumbnail-wrapper { - background-color: var(--selection-ring); -} - -.document-card.is-dragging { - opacity: 0.55; -} - -.document-card__meta { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.35rem; - width: 100%; -} - -.document-card__title { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.35rem; - color: var(--fg); -} - -.document-card__title-row { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -.document-card__title-badge { - padding: 0.2rem 0.7rem; - border-radius: 1rem; - max-width: 100%; - word-break: break-word; - font-size: var(--documents-grid-title-size); - color: inherit; -} - -.document-card .doc-correspondent-link { - font-size: var(--documents-grid-title-size); -} - -.document-card.selected .document-card__title-badge { - background-color: var(--accent); - color: var(--on-accent); -} - -.document-card__subtitle { - font-size: 0.78rem; - color: var(--muted); -} - -.document-card__tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - justify-content: center; -} - -.folder-card { - align-items: center; - text-align: center; -} - -.folder-card__icon { - display: flex; - align-items: center; - justify-content: center; - width: var(--documents-grid-icon-size); - height: var(--documents-grid-icon-size); -} - -.folder-card__icon-svg { - width: 100%; - height: 100%; - object-fit: contain; -} - -.folder-card__meta { - display: flex; - flex-direction: column; - gap: 0.28rem; - padding-top: 0.35rem; -} - -.folder-card__label-row { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -.folder-card__name { - color: var(--fg); - overflow: hidden; - text-overflow: ellipsis; - white-space: break-word; - font-size: var(--documents-grid-title-size); - padding: 0.2rem 0.7rem; - border-radius: 1rem; -} - -.folder-card__edit { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; -} - -.folder-card.selected .folder-card__name { - background-color: var(--accent); - color: var(--on-accent); -} - - -.doc-name__title { - max-width: 100%; - word-break: break-word; -} - -.doc-name__tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; -} - -.doc-correspondents { - display: inline; - color: inherit; -} - -.doc-correspondent-link { - background: none; - background-color: transparent; - border: none; - padding: 0; - margin: 0; - color: var(--accent); - text-decoration: none; - font: inherit; - cursor: pointer; - box-shadow: none; - appearance: none; -} - -.doc-correspondent-link:not(.is-static):hover, -.doc-correspondent-link:not(.is-static):focus-visible { - color: var(--accent-strong, var(--accent)); - text-decoration: underline; - text-decoration-thickness: 1.5px; - background-color: transparent; -} - -.doc-correspondent-link:not(.is-static):focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; -} - -.documents-panel tbody tr.document.selected .doc-correspondents, -.documents-panel tbody tr.document.selected .doc-correspondent-link, -.document-card.selected .doc-correspondent-link { - color: inherit; -} - -.document-card.selected .doc-correspondents { - color: inherit; -} - -.doc-correspondent-link.is-active { - font-weight: 600; -} - -.doc-correspondent-link.is-static { - color: inherit; - cursor: default; - text-decoration: none; -} - -.doc-correspondent-link__separator { - color: inherit; -} - -.documents-panel tbody tr.document.dragging { - opacity: 0.4; -} - -.documents-panel tbody tr.document.tag-drop-target { - background: var(--accent-soft); - box-shadow: inset 0 0 0 2px var(--accent); -} - -.document-card.tag-drop-target { - box-shadow: 0 0 0 2px var(--accent); - border-color: var(--accent); -} - -.document-card.tag-drop-target .document-card__title { - color: var(--accent); -} - -.filter-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.32rem; - margin-bottom: 0.5rem; -} - -.filter-bar input[type='search'] { - flex: 1; - min-width: 180px; -} - -.filter-actions { - display: flex; - gap: 0.32rem; -} - -.search-hint { - margin-top: 0.75rem; - font-size: 0.85rem; - color: var(--muted); -} - -.badge { - display: inline-flex; - align-items: center; - padding: 0.15rem 0.35rem; - border-radius: 2px; - background: var(--surface-subtle); - color: var(--fg); - font-size: 0.74rem; -} - -.tag-chip { - gap: 0.25rem; - border-radius: 1rem; - padding: 0.2rem 0.4rem; - font-weight: 600; -} - -.tag-chip--more { - background: transparent; - border-color: var(--border-strong); - color: var(--muted); -} - -.tag-chip--removable { - gap: 0.35rem; -} - -.tag-chip__remove { - background: none; - border: none; - color: inherit; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - cursor: pointer; - opacity: 0.8; -} - -.tag-chip__remove:hover, -.tag-chip__remove:focus-visible { - opacity: 1; -} - -.tag-chip__remove:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - border-radius: 50%; -} - -.empty-state { - border: 1px dashed var(--border); - border-radius: 0; - text-align: center; - padding: 1.1rem 0.9rem; - color: var(--muted); - margin-top: 0.75rem; - background: var(--surface-subtle); -} - -.empty-state--global { - border-style: solid; -} - -.panel.detail-panel { - position: fixed; - top: 0; - right: 0; - width: min(100vw, var(--detail-panel-width)); - min-height: 100vh; - height: 100%; - height: 100%; - background: var(--bg); - box-shadow: 0 0 24px var(--shadow-soft); - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - z-index: 1000000; -} - -.panel-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem; - min-width: 0; -} - -.panel-header__leading, -.panel-header__actions { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.panel-header__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - line-height: 1.2; - flex: 1 1 auto; - min-width: 0; - display: flex; - align-items: center; - gap: 0.5rem; - overflow: hidden; - text-wrap: nowrap; -} - -.panel-header > .panel-header__title:first-child { - padding-left: 0.5rem; -} - -.panel-header__actions { - margin-left: auto; -} - -.panel-body { - padding: 0.5rem 1rem; -} - - -.detail-panel .panel-body { - flex: 1; - display: flex; - flex-direction: column; - overflow-y: auto; - min-height: 0; - padding: 0; -} - -.detail-section__header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.detail-section__title { - margin: 0; - font-weight: 600; - flex: 1; - min-width: 0; -} - -.quick-add { - display: inline-flex; - align-items: center; - position: relative; -} - -.quick-add__trigger { - white-space: nowrap; -} - -.documents-sort__trigger.quick-add__trigger { - padding: 0.25rem 0.5rem; - min-height: 2.1rem; -} - -.quick-add__chip { - display: inline-flex; - align-items: center; - gap: 0.35rem; - white-space: nowrap; - border: 1px dashed var(--border); - border-radius: 999px; - padding: 0.18rem 0.6rem; - font-size: 0.85rem; - background: transparent; - color: var(--muted); - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; -} - -.quick-add__chip:hover, -.quick-add__chip:focus-visible { - border-style: solid; - background: var(--selection-soft); - color: var(--fg); - outline: none; -} - -.quick-add__chip .icon-inline { - width: 1rem; - height: 1rem; -} - -.quick-add__chip-label { - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.quick-add__menu { - padding: 0.25rem 0; -} - -.quick-add__form { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border); -} - -.quick-add__form input { - flex: 1; - min-width: 0; -} - -.quick-add__list { - max-height: 240px; - overflow-y: auto; -} - -.quick-add__option { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.quick-add__swatch { - width: 0.75rem; - height: 0.75rem; - border-radius: 9999px; - border: 1px solid var(--border); - background: var(--surface-subtle); -} - -.selection-assignment { - display: inline-flex; - position: relative; -} - -.selection-assignment__menu { - font-size: 0.95rem; - font-weight: 400; - padding: 0.4rem 0; - max-width: min(22rem, 90vw); -} - -.selection-assignment__header { - padding: 0.4rem 0.75rem 0.3rem; - border-bottom: 1px solid var(--border-subtle); -} - -.selection-assignment__header input { - width: 100%; - padding: 0.35rem 0.6rem; - border: 1px solid var(--border-subtle); - border-radius: 0.5rem; - background: var(--surface-subtle); - color: var(--fg); - font-size: 0.95rem; -} - -.selection-assignment__header input:focus-visible { - outline: none; - border-color: var(--selection-border); - box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent); -} - -.selection-assignment__list { - max-height: 240px; - overflow-y: auto; -} - -.selection-assignment__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - font-weight: 400; -} - -.selection-assignment__label { - flex: 1 1 auto; - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.selection-assignment__spinner { - margin-left: 0.4rem; -} - -.selection-assignment__label--nowrap { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.selection-assignment__indent { - display: inline-block; - flex: 0 0 auto; -} - -.selection-assignment__folder-label { - display: inline-flex; - flex-direction: column; - gap: 0.15rem; - min-width: 0; - max-width: 16rem; -} - -.selection-assignment__folder-name { - font-weight: 500; - color: var(--fg); - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.selection-assignment__folder-path { - font-size: 0.8rem; - color: var(--muted); - white-space: normal; - word-break: break-word; -} - -.selection-assignment__slash { - color: var(--muted); - margin: 0 0.25rem; -} - -.selection-assignment__segment { - display: inline-block; -} - -.selection-assignment__item--all .selection-assignment__icon { - color: var(--success); -} - -.selection-assignment__item--partial .selection-assignment__icon { - color: var(--warning); -} - -.selection-assignment__icon { - width: 1rem; - height: 1rem; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.selection-assignment__icon--empty { - border: 1px solid var(--border-subtle); - border-radius: 999px; - opacity: 0.6; -} - -.selection-assignment__label { - flex: 1 1 auto; - min-width: 0; - text-align: left; - font-weight: 400; -} - -.selection-assignment__count { - font-size: 0.8rem; - color: var(--muted); -} - -.selection-assignment__empty { - padding: 0.75rem; -} - -.selection-assignment__create { - border-top: 1px solid var(--border-subtle); - display: flex; - align-items: center; - gap: 0.5rem; -} - - -.preview-pane { - margin-top: 0.4rem; - background: transparent; - display: flex; - flex-direction: column; - gap: 1.25rem; - min-height: 0; - flex: 1; - overflow: auto; - position: relative; - padding: 1.25rem; -} - -.preview-pane__media { - border-radius: 0; - background: transparent; - min-height: 220px; - display: flex; - align-items: center; - justify-content: center; - overflow: visible; - position: relative; -} - -.preview-image { - width: 100%; - max-width: 360px; - max-height: 100%; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - pointer-events: auto; - position: relative; -} - -.preview-image__content { - display: block; - width: 100%; - height: auto; - max-width: 100%; - max-height: 100%; - object-fit: contain; - background: transparent; - cursor: pointer; - transition: - outline-color 120ms ease, - box-shadow 120ms ease, - filter 120ms ease, - background-color 120ms ease; - outline: 2px solid transparent; - outline-offset: -2px; -} - -.preview-image__content:hover, -.preview-image__content:focus-visible { - outline-color: var(--accent-focus); - box-shadow: - inset 0 0 0 999px var(--accent-elevated), - 0 6px 18px var(--accent-elevated-strong); -} - -.thumbnail-preview { - display: flex; - flex-direction: column; - gap: 0.32rem; - margin: 0.4rem 0 0.6rem; -} - -.thumbnail-image { - max-width: 100%; - border-radius: 0; - box-shadow: none; -} - -.detail-panel .meta, -.document-summary .meta { - font-size: 0.9rem; - color: var(--muted); - margin: 0.5rem 0; -} - -.detail-panel .detail-folder-path, -.document-summary .detail-folder-path { - display: inline-flex; - flex-wrap: wrap; - gap: 0.25rem; - align-items: center; -} - -.detail-folder-path--block { - display: flex; - gap: 0.25rem; - flex-wrap: wrap; - align-items: center; - margin: 0.5rem 0; -} - -.detail-panel .detail-folder-path__link, -.document-summary .detail-folder-path__link { - color: var(--accent); - text-decoration: none; -} - -.detail-panel .detail-folder-path__link:hover, -.detail-panel .detail-folder-path__link:focus-visible, -.document-summary .detail-folder-path__link:hover, -.document-summary .detail-folder-path__link:focus-visible { - text-decoration: underline; - outline: none; -} - -.detail-panel .detail-folder-path__separator, -.document-summary .detail-folder-path__separator { - color: var(--muted); -} - -.detail-panel .detail-folder-path__segment, -.document-summary .detail-folder-path__segment { - color: var(--fg); -} - -.detail-panel .doc-title-row, -.document-summary .doc-title-row { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin: 0.25rem 0 1.25rem; - max-width: 100%; - word-break: break-word; -} - -.doc-title-row__primary { - display: flex; - align-items: center; - gap: 0.4rem; - width: 100%; -} - -.doc-title-row__title { - margin: 0; -} - -.doc-title-row__path { - font-size: 0.85rem; - color: var(--muted); - display: flex; - flex-wrap: wrap; - gap: 0.25rem; -} - -.detail-panel .doc-title-edit, -.document-summary .doc-title-edit { - display: flex; - align-items: center; - gap: 0.4rem; - width: 100%; -} - -.detail-panel .doc-title-edit input, -.document-summary .doc-title-edit input { - flex: 1; - min-width: 0; -} - -.status-inline { - font-size: 0.85rem; - margin-top: 0.2rem; -} - -.detail-meta { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin: 1rem 0; -} - -.detail-meta__row { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.9rem; - color: var(--muted); -} - -.detail-meta__label { - font-weight: 600; - color: var(--fg); -} - -.detail-meta__value { - color: var(--fg); -} - -.detail-meta__row > .icon-button { - margin: -0.25rem; -} - -.doc-issued-row { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.9rem; - color: var(--muted); - margin: 0.25rem 0 0.5rem; -} - -.doc-issued-row__label { - font-weight: 600; - color: var(--fg); -} - -.doc-issued-row__value { - color: var(--fg); -} - -.doc-issued-edit { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.doc-issued-edit input[type='date'] { - font: inherit; - padding: 0.35rem 0.5rem; -} - -.status-inline.error { - color: var(--danger); -} - -.bulk-detail-actions { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0.6rem 0; -} - -.bulk-detail-actions .inline { - display: flex; - gap: 0.4rem; -} - -.bulk-move { - display: flex; - flex-direction: column; - gap: 0.3rem; - margin: 0.6rem 0; -} - -.bulk-move select { - max-width: 100%; -} - -.preview-stack { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -.preview-stack--stacked { - width: 100%; - min-height: 420px; - flex-shrink: 0; -} - -.preview-stack--empty { - width: 100%; - min-height: 420px; - flex-shrink: 0; -} - -.preview-stack__item { - position: absolute; - top: 50%; - left: 50%; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - transition: transform 120ms ease; - filter: drop-shadow(0 2px 6px var(--shadow-soft)); - transform-origin: center; - border-radius: 6px; - pointer-events: none; -} - -.preview-stack .preview-stack__item.orientation-portrait { - width: 80%; - height: 100%; -} - -.preview-stack .preview-stack__item.orientation-landscape { - width: 100%; - height: 80%; -} - -.preview-pane__unsupported { - width: 100%; - max-width: 320px; - display: flex; - flex-direction: column; - gap: 0.6rem; - align-items: center; - text-align: center; - padding: 1.5rem; - border-radius: 8px; - background: var(--surface-subtle); - color: var(--fg); -} - -.preview-pane__unsupported-message { - font-size: 0.95rem; - font-weight: 500; -} - -.preview-pane__unsupported-filename { - font-size: 0.85rem; - color: var(--muted); - word-break: break-word; -} - -.preview-pane__unsupported-download { - font-weight: 600; -} - -.preview-pane__unsupported-download svg { - width: 1rem; - height: 1rem; -} - -.preview-pane--stack { - min-height: 460px; - position: relative; - --preview-nav-scale: 1; -} - -.preview-pane__nav-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.2em; - height: 2.2em; - padding: 0.45em; - border-radius: 999px; - background: var(--preview-nav-bg); - color: var(--preview-nav-fg); - cursor: pointer; - transition: background 0.15s ease, opacity 0.15s ease; - pointer-events: auto; -} - -.preview-pane__nav-button:hover:not([disabled]) { - background: var(--preview-nav-bg-hover); -} - -.preview-pane__nav-button:disabled { - opacity: 0.4; - cursor: default; -} - -.preview-pane__nav-button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.preview-pane__nav { - margin-top: 0.5rem; - display: flex; - justify-content: center; - gap: 0.5rem; -} - -.preview-pane__nav--overlay { - position: absolute; - bottom: 0.75rem; - left: 50%; - transform: translateX(-50%) scale(var(--preview-nav-scale, 1)); - margin-top: 0; - pointer-events: none; - z-index: 20; - opacity: 0; - transition: opacity 0.2s ease; -} - -.preview-pane__nav--overlay .preview-pane__nav-button { - pointer-events: auto; - transform: scale(calc(1 / var(--preview-nav-scale, 1))); -} - -.preview-pane__media:hover .preview-pane__nav--overlay, -.desk-item__card:hover .preview-pane__nav--overlay { - opacity: 1; -} - -.desk-card__nav { - bottom: 0.5rem; -} - -.bulk-tags { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - margin: 0.4rem 0 0.6rem; -} - -.detail-field { - margin: 0.9rem 0; -} - -.detail-field__label { - font-weight: 600; - margin-bottom: 0.25rem; -} - -.detail-field__value { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - margin-bottom: 0.5rem; -} - -.detail-field__value .meta { - color: var(--muted); -} - -.detail-panel dl { - margin: 0; -} - -.detail-panel dt { - font-weight: 600; - margin-top: 0.8rem; -} - -.detail-panel dd { - margin: 0.2rem 0 0; -} - -.detail-panel .tag-list, -.document-summary .tag-list, -.correspondent-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin: 1rem 0; - align-items: center; -} - -.tag-list__empty { - color: var(--muted); -} - -.detail-metadata__block { - margin: 0.35rem 0 0; - padding: 0.5rem 0.6rem; - background: var(--surface-soft); - border-radius: 4px; - border: 1px solid var(--border-muted, var(--border)); - white-space: pre-wrap; - word-break: break-word; - font-family: var(--font-mono); - font-size: 0.82rem; - line-height: 1.45; -} - -input, -textarea, -select { - font: inherit; - border-radius: 2px; - border: 1px solid var(--border); - padding: 0.4rem 0.5rem; - background: var(--surface); - color: inherit; - width: 100%; - box-sizing: border-box; -} - -input:focus, -textarea:focus, -select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-soft); - background: var(--surface); -} - -textarea { - resize: vertical; -} - -form.inline { - display: flex; - gap: 0.32rem; - margin-top: 0.5rem; -} - -.status-banner { - padding: 0.45rem 0.8rem; - border-radius: 2px; - font-size: 0.85rem; -} - -.status-banner.info { - background: var(--surface-subtle); - color: var(--muted); -} - -.status-banner.success { - background: var(--success-subtle); - color: var(--success); -} - -.status-banner.error { - background: var(--danger-soft); - color: var(--danger); -} - -.drop-overlay { - position: fixed; - inset: 0; - background: var(--accent-soft); - backdrop-filter: blur(4px); - display: none; - align-items: center; - justify-content: center; - z-index: 9999; -} - -.drop-overlay.active { - display: flex; -} - -.drop-overlay__content { - background: var(--surface); - color: var(--fg); - padding: 1.25rem 1.75rem; - border-radius: 2px; - text-align: center; - font-size: 0.95rem; - box-shadow: none; -} -.modal-backdrop { - position: fixed; - inset: 0; - background: var(--overlay-backdrop); - display: flex; - align-items: center; - justify-content: center; - padding: 1.5rem; - z-index: 3000; -} - -.modal { - background: var(--surface); - border-radius: 4px; - box-shadow: 0 18px 42px var(--overlay-shadow); - width: min(360px, 100%); - padding: 1.6rem; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.modal--panel { - width: 80vw; - max-width: 960px; - max-height: 90vh; - padding: 0; - border-radius: 12px; - overflow: hidden; - gap: 0; -} - -.settings-modal__body { - display: grid; - grid-template-columns: 12rem 1fr; - gap: 1.5rem; - height: 90vh; - padding: 1.5rem; -} - -.settings-modal__sidebar { - border-right: 1px solid var(--border-muted, var(--border)); - padding-right: 1rem; -} - -.settings-modal__sidebar ul { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.settings-modal__sidebar button { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 0.4rem; - border: none; - background: none; - color: inherit; - font: inherit; - padding: 0.45rem 0.6rem; - border-radius: 0.35rem; - cursor: pointer; -} - -.settings-modal__sidebar button:hover, -.settings-modal__sidebar button:focus-visible { - background: var(--sidebar-hover-bg); -} - -.settings-modal__sidebar button.active { - background: var(--accent-soft); - font-weight: 600; -} - -.settings-modal__content { - overflow-y: auto; - padding-bottom: 1rem; -} - -.settings-section h4 { - margin-top: 0; -} - -.settings-section { - display: flex; - flex-direction: column; -} - -.settings-actions { - display: flex; - justify-content: flex-end; - margin-bottom: 1rem; -} - -.settings-actions .secondary { - min-width: 7rem; -} - -.settings-form { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: flex-end; - margin-bottom: 0.75rem; -} - -.settings-form__field { - display: flex; - flex-direction: column; - gap: 0.35rem; - min-width: 14rem; -} - -.settings-form__field--full { - flex: 1 1 100%; - min-width: 100%; -} - -fieldset.settings-form__field { - border: 1px solid var(--border-muted, var(--border)); - border-radius: 0.5rem; - padding: 0.75rem 0.9rem 0.85rem; - background: var(--surface-soft); -} - -fieldset.settings-form__field legend { - padding: 0 0.35rem; - font-weight: 600; - color: var(--muted-strong, inherit); -} - -.settings-form__field input[type='text'], -.settings-form__field input[type='datetime-local'] { - padding: 0.45rem 0.6rem; - border: 1px solid var(--border); - border-radius: 0.35rem; - background: var(--surface-soft); - color: inherit; -} - -.settings-form__choices { - display: flex; - flex-direction: column; - gap: 0.4rem; - align-items: flex-start; -} - -.settings-capability-picker { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.capability-dropdown { - position: relative; - width: 100%; -} - -.capability-dropdown__trigger { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 0.45rem; - background: var(--surface-soft); - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.capability-dropdown__trigger:hover:not(:disabled), -.capability-dropdown__trigger:focus-visible { - border-color: var(--accent); - background: var(--surface); - outline: none; -} - -.capability-dropdown__trigger:disabled { - cursor: not-allowed; - color: var(--muted); - background: var(--surface-muted, var(--surface-soft)); -} - -.capability-dropdown__chevron { - flex-shrink: 0; - opacity: 0.8; -} - -.capability-dropdown__menu { - margin-top: 0.35rem; - max-height: 18rem; - overflow-y: auto; - padding: 0.25rem 0; - width: 100%; -} - -.capability-dropdown__option { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.45rem 0.75rem; -} - -.capability-dropdown__option-icon { - width: 1.1rem; - display: flex; - align-items: center; - justify-content: center; - color: var(--accent); -} - -.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon { - color: transparent; -} - -.capability-dropdown__option-label { - flex: 1; - text-align: left; -} - -.capability-dropdown__empty { - padding: 0.6rem 0.8rem; - color: var(--muted); -} - -.settings-capability-picker__chips { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.25rem; -} - -.settings-capability-picker__chips--inline { - margin-top: 0.35rem; -} - -.settings-capabilities-summary { - display: inline-block; - margin-top: 0.4rem; - color: var(--muted); - font-size: 0.9rem; -} - -.settings-capability-picker__placeholder { - color: var(--muted); - font-size: 0.9rem; -} - -.settings-capability-list { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - margin-top: 0.35rem; -} - -.settings-capability-list--compact { - margin-top: 0.2rem; - gap: 0.25rem; -} - -.settings-capability-list__item { - display: inline-flex; - align-items: center; -} - -.settings-choice { - display: inline-flex; - align-items: center; - gap: 0.4rem; - font-size: 0.9rem; - font-weight: 500; - color: inherit; -} - -.settings-choice input[type='checkbox'] { - width: 1.05rem; - height: 1.05rem; - cursor: pointer; - accent-color: var(--accent); -} - -.settings-choice input[type='checkbox']:disabled + span { - color: var(--muted); -} - -.settings-form__actions { - display: flex; - gap: 0.5rem; -} - -.settings-form__error { - color: var(--danger); - font-size: 0.85rem; - margin: 0 0 0.75rem; -} - -.settings-empty { - margin: 1rem 0; - color: var(--muted); -} - -.settings-table { - width: 100%; - border-collapse: collapse; - margin-top: 0.5rem; -} - -.settings-table th, -.settings-table td { - padding: 0.55rem 0.75rem; - border-bottom: 1px solid var(--border-muted, var(--border)); - text-align: left; - font-size: 0.9rem; -} - -.settings-table tr:last-child td { - border-bottom: none; -} - -.settings-table__actions { - white-space: nowrap; -} - -.settings-status { - color: var(--muted); - font-size: 0.9rem; -} - -.settings-table tr.is-revoked { - opacity: 0.65; -} - -.settings-notice { - border: 1px solid var(--accent); - background: var(--accent-soft); - padding: 0.9rem 1rem; - border-radius: 0.5rem; - margin-bottom: 1rem; -} - -.token-display { - background: var(--surface-ink-soft); - padding: 0.5rem 0.65rem; - border-radius: 0.35rem; - font-family: var(--font-mono); - font-size: 0.95rem; - overflow-x: auto; -} - -.settings-notice__actions { - display: flex; - gap: 0.5rem; - margin-top: 0.6rem; -} - -.settings-notice__actions button { - flex: 0 0 auto; -} - -.panel-modal__header { - padding: 1rem 1rem 1rem 0.5rem; - border-bottom: 1px solid var(--border); -} - -.panel-modal__body { - flex: 1; - overflow: auto; - padding: 0; -} - -.modal__body { - width: 100%; -} - -.modal h3 { - margin: 0; - font-size: 1.15rem; - font-weight: 600; -} - -.modal__form { - display: flex; - flex-direction: column; - gap: 0.9rem; -} - -.modal__form label { - font-weight: 600; - font-size: 0.9rem; -} - -.modal__error { - margin: -0.45rem 0 0; - font-size: 0.85rem; - color: var(--danger); -} - -.modal__actions { - display: flex; - justify-content: flex-start; - padding: 0.25rem; - gap: 0.6rem; -} - -@media (max-width: 768px) { - .app-bar { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - .app-main { - grid-template-columns: 1fr; - } - .sidebar, - .documents-panel, - .detail-panel, - .tags-panel, - .correspondents-panel { - min-height: auto; - } -} -.login-screen { - min-height: 100vh; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg); - padding: 2rem; -} - -.login-card { - width: min(340px, 100%); - background: var(--surface); - border-radius: 2px; - padding: 1.75rem; - box-shadow: none; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.login-card h1 { - margin: 0; - font-size: 1.6rem; - font-weight: 600; -} - -.login-card p { - margin: 0; - color: var(--muted); - font-size: 0.9rem; -} - -.login-card form { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.login-card label { - font-size: 0.85rem; - font-weight: 600; - color: var(--muted); -} - -.login-card__fields { - display: flex; - flex-direction: column; - gap: 0.75rem; - width: 100%; -} - -.login-card__passkey-button { - margin-top: 1rem; - width: 100%; - display: inline-flex; - justify-content: center; -} - -.login-card__signup-button { - margin-top: 0.75rem; - width: 100%; - display: inline-flex; - justify-content: center; -} - -.login-card .status-banner { - margin-bottom: 0; -} - -.login-card__selection { - display: flex; - flex-direction: column; - gap: 0.9rem; -} - -.login-card__tenant-list { - display: flex; - flex-direction: column; - gap: 0.6rem; -} - -.login-card__tenant-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - padding: 0.65rem 0.75rem; - border-radius: 0.6rem; - border: 1px solid var(--border); - background: var(--surface); - color: inherit; - font-weight: 600; - font-size: 0.95rem; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; -} - -.login-card__tenant-button:hover:not([disabled]) { - background: var(--surface-hover); - border-color: var(--border-strong); -} - -.login-card__tenant-button:disabled { - opacity: 0.6; - cursor: wait; -} - -.login-card__tenant-button.is-loading { - opacity: 0.6; -} - -.login-card__back-button { - align-self: flex-start; - background: none; - border: none; - padding: 0; - color: var(--accent); - font-size: 0.85rem; - cursor: pointer; - text-decoration: underline; -} - -.login-card__back-button:hover { - text-decoration: none; -} - -.login-card__back-button:disabled { - opacity: 0.6; - cursor: default; -} -.doc-list__name { - width: 100%; -} - -.doc-list__name-content { - display: inline-flex; - align-items: center; - gap: 0.22rem; - max-width: 100%; -} - -.doc-list__name-content span { - max-width: 100%; - overflow-wrap: anywhere; - word-break: break-word; -} - -.preview-pane__nav-button svg { - width: 100%; - height: 100%; -} - -.panel-header .icon-button svg { - width: 1.41rem; - height: 1.41rem; -} diff --git a/frontend/src/styles/auth/login.css b/frontend/src/styles/auth/login.css new file mode 100644 index 0000000..91cff78 --- /dev/null +++ b/frontend/src/styles/auth/login.css @@ -0,0 +1,149 @@ +.login-screen { + min-height: 100vh; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + padding: 2rem; +} + +.login-card { + width: min(340px, 100%); + background: var(--surface); + border-radius: 2px; + padding: 1.75rem; + box-shadow: none; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-card h1 { + margin: 0; + font-size: 1.6rem; + font-weight: 600; +} + +.login-card p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.login-card label { + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); +} + +.login-card__fields { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; +} + +.login-card .login-card__fields input { + appearance: none; + border-radius: 0.45rem; + border: 1px solid var(--border); + padding: 0.55rem 0.65rem; + font-size: 0.95rem; + background: var(--surface); + color: var(--fg); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.login-card .login-card__fields input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); + background: var(--surface); +} + +.login-card__passkey-button { + margin-top: 1rem; + width: 100%; + display: inline-flex; + justify-content: center; +} + +.login-card__signup-button { + margin-top: 0.75rem; + width: 100%; + display: inline-flex; + justify-content: center; +} + +.login-card .status-banner { + margin-bottom: 0; +} + +.login-card__selection { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.login-card__tenant-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.login-card__tenant-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 0.6rem; + border: 1px solid var(--border); + background: var(--surface); + color: inherit; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.login-card__tenant-button:hover:not([disabled]) { + background: var(--surface-hover); + border-color: var(--border-strong); +} + +.login-card__tenant-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.login-card__tenant-button.is-loading { + opacity: 0.6; +} + +.login-card__back-button { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; + text-decoration: underline; +} + +.login-card__back-button:hover { + text-decoration: none; +} + +.login-card__back-button:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/frontend/src/styles/base/controls.css b/frontend/src/styles/base/controls.css new file mode 100644 index 0000000..68b8bbe --- /dev/null +++ b/frontend/src/styles/base/controls.css @@ -0,0 +1,99 @@ +button { + font: inherit; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; + background: var(--accent); + color: var(--on-accent); + cursor: pointer; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +button[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +a.button-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font: inherit; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; + background: var(--accent); + color: var(--on-accent); + text-decoration: none; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +a.button-link[aria-disabled='true'] { + opacity: 0.55; +} + +a.button-link:hover:not([aria-disabled='true']) { + background: var(--accent-hover); +} + +button.secondary { + background: transparent; + color: var(--fg); + border-color: var(--border); +} + +button.secondary:hover:not([disabled]) { + background: var(--surface-subtle); +} + +button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger-border); +} + +button.danger:hover:not([disabled]) { + border-color: var(--danger); + background: var(--danger-subtle); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + padding: 0.25rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.icon-button:hover:not([disabled]) { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.icon-button.danger { + color: var(--danger); +} + +.icon-button.danger:hover:not([disabled]) { + background: var(--danger-subtle); +} + +.icon-button.ghost { + border: none; + background: transparent; + color: var(--muted); + padding: 0.2rem; +} + +.icon-button.ghost:hover:not([disabled]) { + color: var(--danger); + background: transparent; +} + diff --git a/frontend/src/styles/base/iconography.css b/frontend/src/styles/base/iconography.css new file mode 100644 index 0000000..fa84f2a --- /dev/null +++ b/frontend/src/styles/base/iconography.css @@ -0,0 +1,56 @@ +.with-icon { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.icon-inline { + width: 1rem; + height: 1rem; +} + +.icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +@keyframes icon-spin { + to { + transform: rotate(360deg); + } +} + +.icon--spin { + animation: icon-spin 0.9s linear infinite; +} + +.icon--flip-y { + transform: scaleX(-1); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.icon path { + stroke: currentColor; + stroke-width: 1.6; + fill: none; +} + +.icon--fill path { + stroke: none; + fill: currentColor; +} + + diff --git a/frontend/src/styles/base/text-button.css b/frontend/src/styles/base/text-button.css new file mode 100644 index 0000000..fceeabc --- /dev/null +++ b/frontend/src/styles/base/text-button.css @@ -0,0 +1,14 @@ +.text-button { + background: none; + border: none; + color: var(--accent); + font-weight: 500; + font-size: 0.8rem; + cursor: pointer; + padding: 0.1rem 0.25rem; +} + +.text-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css new file mode 100644 index 0000000..6b7db9f --- /dev/null +++ b/frontend/src/styles/base/theme.css @@ -0,0 +1,295 @@ +:root { + color-scheme: light dark; + + --neutral-hue: 180deg; + --foreground-hue-offset: 10deg; + --accent-hue-offset: -20deg; + --dark-hue-offset: 30deg; + --dark-foreground-hue-offset: 10deg; + --surface-hue-shift: -4deg; + + /* --- Neutrals --- */ + --bg: oklch(0.97 0.001 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(0.985 0.001 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(0.96 0.002 calc(var(--neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted: oklch(0.54 0.008 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-fg: oklch(0.56 0.007 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --border: oklch(0.92 0.002 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + + /* --- Accent (primary) --- */ + --accent: oklch(0.61 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent-hover: oklch(0.70 0.12 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --on-accent: oklch(1 0 0); + + --folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 60%, white 30%); + --folder-icon-front: color-mix(in oklch, var(--accent) 42%, white 58%); + + --success-hue: 140deg; + --warning-hue: 85deg; + --danger-hue: 25deg; + + --accent-soft: color-mix(in oklch, var(--accent) 12%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 45%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 80%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 75%, transparent); + --surface-overlay: color-mix(in oklch, white 82%, transparent); + + /* -- Selection --- */ + --selection: oklch(0.61 0.01 var(--neutral-hue)); + + /* --- Derived accent states --- */ + --selection-soft: color-mix(in oklch, var(--selection) 12%, transparent); + + /* --- Shadows & overlays --- */ + --shadow-faint: color-mix(in oklch, black 6%, transparent); + --shadow-soft: color-mix(in oklch, black 12%, transparent); + --shadow-medium: color-mix(in oklch, black 18%, transparent); + --shadow-strong: color-mix(in oklch, black 28%, transparent); + --shadow-deep: color-mix(in oklch, black 55%, transparent); + --shadow-pop: color-mix(in oklch, black 25%, transparent); + --outline-subtle: color-mix(in oklch, black 6%, transparent); + --overlay-dark: color-mix(in oklch, black 55%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 20%, transparent); + --overlay-darker: color-mix(in oklch, black 75%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 24%, transparent); + --surface-hover: color-mix(in oklch, white 10%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 45%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 18%, transparent); + + /* --- Semantic colors --- */ + --success: oklch(0.68 0.16 var(--success-hue)); + --warning: oklch(0.76 0.17 var(--warning-hue)); + --danger: oklch(0.64 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 12%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 12%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 12%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent); + + /* --- Explorer states --- */ + --row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent); + --sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent); + --selection-ring: oklch(0.78 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent); + --link: var(--accent); + --link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); + --preview-nav-bg: oklch(0.18 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --preview-nav-bg-hover: oklch(0.14 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --preview-nav-fg: var(--on-accent); + --text-on-dark: color-mix(in oklch, white 90%, transparent); + --surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent); + + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 15px; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + + --detail-panel-width: 30em; + --documents-grid-title-size: 0.8rem; +} + +:root[data-theme='light'] { + color-scheme: light; +} + +:root[data-theme='dark'] { + color-scheme: dark; + + --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); + --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); + + --bg: oklch(0.15 0.01 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(0.19 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); + --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); + --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + + --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); + + --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); + --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); + + --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 80%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 72%, transparent); + --surface-overlay: color-mix(in oklch, black 60%, transparent); + + --selection: oklch(0.44 0.04 220deg); + --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); + + --shadow-faint: color-mix(in oklch, black 35%, transparent); + --shadow-soft: color-mix(in oklch, black 50%, transparent); + --shadow-medium: color-mix(in oklch, black 65%, transparent); + --shadow-strong: color-mix(in oklch, black 80%, transparent); + --shadow-deep: color-mix(in oklch, black 90%, transparent); + --shadow-pop: color-mix(in oklch, black 70%, transparent); + --outline-subtle: color-mix(in oklch, white 10%, transparent); + --overlay-dark: color-mix(in oklch, black 70%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); + --overlay-darker: color-mix(in oklch, black 85%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); + --surface-hover: color-mix(in oklch, white 6%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); + + --success: oklch(0.62 0.16 var(--success-hue)); + --warning: oklch(0.68 0.17 var(--warning-hue)); + --danger: oklch(0.60 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); + + --row-hover-bg: color-mix(in oklch, white 4%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); + --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); + --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); + --link: color-mix(in oklch, var(--accent) 92%, transparent); + --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); + --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); + --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); + --preview-nav-fg: var(--fg); + --text-on-dark: color-mix(in oklch, white 92%, transparent); + --surface-ink-soft: color-mix(in oklch, white 12%, transparent); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + color-scheme: dark; + + --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); + --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); + + --bg: oklch(0.15 0.01 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(0.19 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); + --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); + --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + + --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); + + --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); + --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); + + --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 80%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 72%, transparent); + --surface-overlay: color-mix(in oklch, black 60%, transparent); + + --selection: oklch(0.44 0.04 220deg); + --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); + + --shadow-faint: color-mix(in oklch, black 35%, transparent); + --shadow-soft: color-mix(in oklch, black 50%, transparent); + --shadow-medium: color-mix(in oklch, black 65%, transparent); + --shadow-strong: color-mix(in oklch, black 80%, transparent); + --shadow-deep: color-mix(in oklch, black 90%, transparent); + --shadow-pop: color-mix(in oklch, black 70%, transparent); + --outline-subtle: color-mix(in oklch, white 10%, transparent); + --overlay-dark: color-mix(in oklch, black 70%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); + --overlay-darker: color-mix(in oklch, black 85%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); + --surface-hover: color-mix(in oklch, white 6%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); + + --success: oklch(0.62 0.16 var(--success-hue)); + --warning: oklch(0.68 0.17 var(--warning-hue)); + --danger: oklch(0.60 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); + + --row-hover-bg: color-mix(in oklch, white 4%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); + --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); + --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); + --link: color-mix(in oklch, var(--accent) 92%, transparent); + --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); + --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); + --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); + --preview-nav-fg: var(--fg); + --text-on-dark: color-mix(in oklch, white 92%, transparent); + --surface-ink-soft: color-mix(in oklch, white 12%, transparent); + } +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + overflow: hidden; +} + +body.has-main-content { + background: var(--surface); +} + +a { + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + transition: color 0.15s ease, text-decoration-color 0.15s ease; +} + +a:visited { + color: var(--link-visited); +} + +a:hover, +a:focus-visible { + color: var(--accent-hover); + outline: none; + text-decoration-color: currentColor; +} + +#app { + height: 100%; +} diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css new file mode 100644 index 0000000..d559895 --- /dev/null +++ b/frontend/src/styles/detail/detail-panels.css @@ -0,0 +1,778 @@ +.panel.detail-panel { + position: fixed; + top: 0; + right: 0; + width: min(100vw, var(--detail-panel-width)); + min-height: 100vh; + height: 100%; + height: 100%; + background: var(--bg); + box-shadow: 0 0 24px var(--shadow-soft); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + z-index: 1000000; +} + +.panel-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + min-width: 0; +} + +.panel-header__leading, +.panel-header__actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.panel-header__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; + text-wrap: nowrap; +} + +.panel-header > .panel-header__title:first-child { + padding-left: 0.5rem; +} + +.panel-header__actions { + margin-left: auto; +} + +.panel-body { + padding: 0.5rem 1rem; +} + + +.detail-panel .panel-body { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-height: 0; + padding: 0; +} + +.detail-section__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.detail-section__title { + margin: 0; + font-weight: 600; + flex: 1; + min-width: 0; +} + +.quick-add { + display: inline-flex; + align-items: center; + position: relative; +} + +.quick-add__trigger { + white-space: nowrap; +} + +.documents-sort__trigger.quick-add__trigger { + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + +.quick-add__chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + border: 1px dashed var(--border); + border-radius: 999px; + padding: 0.18rem 0.6rem; + font-size: 0.85rem; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.quick-add__chip:hover, +.quick-add__chip:focus-visible { + border-style: solid; + background: var(--selection-soft); + color: var(--fg); + outline: none; +} + +.quick-add__chip .icon-inline { + width: 1rem; + height: 1rem; +} + +.quick-add__chip-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.quick-add__menu { + padding: 0.25rem 0; +} + +.quick-add__form { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.quick-add__form input { + flex: 1; + min-width: 0; +} + +.quick-add__list { + max-height: 240px; + overflow-y: auto; +} + +.quick-add__option { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.quick-add__swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + border: 1px solid var(--border); + background: var(--surface-subtle); +} + +.selection-assignment { + display: inline-flex; + position: relative; +} + +.selection-assignment__menu { + font-size: 0.95rem; + font-weight: 400; + padding: 0.4rem 0; + max-width: min(22rem, 90vw); +} + +.selection-assignment__header { + padding: 0.4rem 0.75rem 0.3rem; + border-bottom: 1px solid var(--border-subtle); +} + +.selection-assignment__header input { + width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; + background: var(--surface-subtle); + color: var(--fg); + font-size: 0.95rem; +} + +.selection-assignment__header input:focus-visible { + outline: none; + border-color: var(--selection-border); + box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent); +} + +.selection-assignment__list { + max-height: 240px; + overflow-y: auto; + padding: 0 0.75rem; +} + +.selection-assignment__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-weight: 400; +} + +.selection-assignment__label { + flex: 1 1 auto; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.selection-assignment__spinner { + margin-left: 0.4rem; +} + +.selection-assignment__label--nowrap { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selection-assignment__indent { + display: inline-block; + flex: 0 0 auto; +} + +.selection-assignment__folder-label { + display: inline-flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + max-width: 16rem; +} + +.selection-assignment__folder-name { + font-weight: 500; + color: var(--fg); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selection-assignment__folder-path { + font-size: 0.8rem; + color: var(--muted); + white-space: normal; + word-break: break-word; +} + +.selection-assignment__slash { + color: var(--muted); + margin: 0 0.25rem; +} + +.selection-assignment__segment { + display: inline-block; +} + +.selection-assignment__item--all .selection-assignment__icon { + color: var(--success); +} + +.selection-assignment__item--partial .selection-assignment__icon { + color: var(--warning); +} + +.selection-assignment__icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.selection-assignment__icon--empty { + border: 1px solid var(--border-subtle); + border-radius: 999px; + opacity: 0.6; +} + +.selection-assignment__label { + flex: 1 1 auto; + min-width: 0; + text-align: left; + font-weight: 400; +} + +.selection-assignment__count { + font-size: 0.8rem; + color: var(--muted); +} + +.selection-assignment__empty { + padding: 0.75rem; +} + +.selection-assignment__create { + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 0.5rem; +} + + +.preview-pane { + margin-top: 0.4rem; + background: transparent; + display: flex; + flex-direction: column; + gap: 1.25rem; + min-height: 0; + flex: 1; + overflow: auto; + position: relative; + padding: 1.25rem; +} + +.preview-pane__media { + border-radius: 0; + background: transparent; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + position: relative; +} + +.preview-image { + width: 100%; + max-width: 360px; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + pointer-events: auto; + position: relative; +} + +.preview-image__content { + display: block; + width: 100%; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background: transparent; + cursor: pointer; + transition: + outline-color 120ms ease, + box-shadow 120ms ease, + filter 120ms ease, + background-color 120ms ease; + outline: 2px solid transparent; + outline-offset: -2px; +} + +.preview-image__content:hover, +.preview-image__content:focus-visible { + outline-color: var(--accent-focus); + box-shadow: + inset 0 0 0 999px var(--accent-elevated), + 0 6px 18px var(--accent-elevated-strong); +} + +.thumbnail-preview { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin: 0.4rem 0 0.6rem; +} + +.thumbnail-image { + max-width: 100%; + border-radius: 0; + box-shadow: none; +} + +.detail-panel .meta, +.document-summary .meta { + font-size: 0.9rem; + color: var(--muted); + margin: 0.5rem 0; +} + +.detail-panel .detail-folder-path, +.document-summary .detail-folder-path { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; +} + +.detail-folder-path--block { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + align-items: center; + margin: 0.5rem 0; +} + +.detail-panel .detail-folder-path__link, +.document-summary .detail-folder-path__link { + color: var(--accent); + text-decoration: none; +} + +.detail-panel .detail-folder-path__link:hover, +.detail-panel .detail-folder-path__link:focus-visible, +.document-summary .detail-folder-path__link:hover, +.document-summary .detail-folder-path__link:focus-visible { + text-decoration: underline; + outline: none; +} + +.detail-panel .detail-folder-path__separator, +.document-summary .detail-folder-path__separator { + color: var(--muted); +} + +.detail-panel .detail-folder-path__segment, +.document-summary .detail-folder-path__segment { + color: var(--fg); +} + +.detail-panel .doc-title-row, +.document-summary .doc-title-row { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0.25rem 0 1.25rem; + max-width: 100%; + word-break: break-word; +} + +.doc-title-row__primary { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; +} + +.doc-title-row__title { + margin: 0; +} + +.doc-title-row__path { + font-size: 0.85rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.detail-panel .doc-title-edit, +.document-summary .doc-title-edit { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; +} + +.detail-panel .doc-title-edit input, +.document-summary .doc-title-edit input { + flex: 1; + min-width: 0; +} + +.status-inline { + font-size: 0.85rem; + margin-top: 0.2rem; +} + +.detail-meta { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 1rem 0; +} + +.detail-meta__row { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--muted); +} + +.detail-meta__label { + font-weight: 600; + color: var(--fg); +} + +.detail-meta__value { + color: var(--fg); +} + +.detail-meta__row > .icon-button { + margin: -0.25rem; +} + +.doc-issued-row { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--muted); + margin: 0.25rem 0 0.5rem; +} + +.doc-issued-row__label { + font-weight: 600; + color: var(--fg); +} + +.doc-issued-row__value { + color: var(--fg); +} + +.doc-issued-edit { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.doc-issued-edit input[type='date'] { + font: inherit; + padding: 0.35rem 0.5rem; +} + +.status-inline.error { + color: var(--danger); +} + +.bulk-detail-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0.6rem 0; +} + +.bulk-detail-actions .inline { + display: flex; + gap: 0.4rem; +} + +.bulk-move { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0.6rem 0; +} + +.bulk-move select { + max-width: 100%; +} + +.preview-stack { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-stack--stacked { + width: 100%; + min-height: 420px; + flex-shrink: 0; +} + +.preview-stack--empty { + width: 100%; + min-height: 420px; + flex-shrink: 0; +} + +.preview-stack__item { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + transition: transform 120ms ease; + filter: drop-shadow(0 2px 6px var(--shadow-soft)); + transform-origin: center; + border-radius: 6px; + pointer-events: none; +} + +.preview-stack .preview-stack__item.orientation-portrait { + width: 80%; + height: 100%; +} + +.preview-stack .preview-stack__item.orientation-landscape { + width: 100%; + height: 80%; +} + +.preview-pane__unsupported { + width: 100%; + max-width: 320px; + display: flex; + flex-direction: column; + gap: 0.6rem; + align-items: center; + text-align: center; + padding: 1.5rem; + border-radius: 8px; + background: var(--surface-subtle); + color: var(--fg); +} + +.preview-pane__unsupported-message { + font-size: 0.95rem; + font-weight: 500; +} + +.preview-pane__unsupported-filename { + font-size: 0.85rem; + color: var(--muted); + word-break: break-word; +} + +.preview-pane__unsupported-download { + font-weight: 600; +} + +.preview-pane__unsupported-download svg { + width: 1rem; + height: 1rem; +} + +.preview-pane--stack { + min-height: 460px; + position: relative; + --preview-nav-scale: 1; +} + +.preview-pane__nav-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2em; + height: 2.2em; + padding: 0.45em; + border-radius: 999px; + background: var(--preview-nav-bg); + color: var(--preview-nav-fg); + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; + pointer-events: auto; +} + +.preview-pane__nav-button:hover:not([disabled]) { + background: var(--preview-nav-bg-hover); +} + +.preview-pane__nav-button:disabled { + opacity: 0.4; + cursor: default; +} + +.preview-pane__nav-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.preview-pane__nav { + margin-top: 0.5rem; + display: flex; + justify-content: center; + gap: 0.5rem; +} + +.preview-pane__nav--overlay { + position: absolute; + bottom: 0.75rem; + left: 50%; + transform: translateX(-50%) scale(var(--preview-nav-scale, 1)); + margin-top: 0; + pointer-events: none; + z-index: 20; + opacity: 0; + transition: opacity 0.2s ease; +} + +.preview-pane__nav--overlay .preview-pane__nav-button { + pointer-events: auto; + transform: scale(calc(1 / var(--preview-nav-scale, 1))); +} + +.preview-pane__media:hover .preview-pane__nav--overlay, +.desk-item__card:hover .preview-pane__nav--overlay { + opacity: 1; +} + +.desk-card__nav { + bottom: 0.5rem; +} + +.bulk-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.4rem 0 0.6rem; +} + +.detail-field { + margin: 0.9rem 0; +} + +.detail-field__label { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.detail-field__value { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.detail-field__value .meta { + color: var(--muted); +} + +.detail-panel dl { + margin: 0; +} + +.detail-panel dt { + font-weight: 600; + margin-top: 0.8rem; +} + +.detail-panel dd { + margin: 0.2rem 0 0; +} + +.detail-panel .tag-list, +.document-summary .tag-list, +.correspondent-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0; + align-items: center; +} + +.tag-list__empty { + color: var(--muted); +} + +.detail-metadata__block { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.75rem 0; + border-top: 1px solid var(--border); +} diff --git a/frontend/src/styles/documents/controls.css b/frontend/src/styles/documents/controls.css new file mode 100644 index 0000000..8c5c8ac --- /dev/null +++ b/frontend/src/styles/documents/controls.css @@ -0,0 +1,108 @@ +.view-toggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.view-toggle__button { + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + padding: 0.3rem 0.6rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.view-toggle__button:hover, +.view-toggle__button:focus-visible { + background: var(--surface-subtle); + color: var(--fg); + outline: none; +} + +.view-toggle__button.active { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); +} + +.view-toggle__icon { + width: 1.4rem; + height: 1.4rem; +} + +.documents-actions__sort-group { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort { + display: inline-flex; + align-items: center; + position: relative; +} + +.documents-sort__trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + white-space: nowrap; + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + +.documents-sort__label { + display: inline-flex; + align-items: center; + line-height: 1.1; +} + +.documents-sort__trigger-content { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort__quickmenu .menu__item, +.documents-sort__quickmenu .menu__item.active { + font-weight: 400; +} + +.documents-toolbar__toggle { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.3rem; + background: transparent; + color: var(--muted); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.documents-toolbar__toggle:hover:not([disabled]) { + color: var(--fg); + border-color: var(--border); +} + +.documents-toolbar__toggle[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); + background: var(--surface-subtle); +} + +.documents-sort__direction { + padding: 0.3rem 0.45rem; +} + +.documents-sort__direction[aria-pressed='true'] { + border-color: var(--border); + color: var(--muted); + background: transparent; +} + +.documents-sort__direction svg { + width: 1.1rem; + height: 1.1rem; +} + diff --git a/frontend/src/styles/documents/listing.css b/frontend/src/styles/documents/listing.css new file mode 100644 index 0000000..751336e --- /dev/null +++ b/frontend/src/styles/documents/listing.css @@ -0,0 +1,736 @@ +.documents-panel { + padding: 0 0.75rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + min-height: 0; +} + +.tags-panel, +.correspondents-panel { + padding: 1.25rem; + display: flex; + flex-direction: column; + min-height: 0; +} + +.folder-row.is-drop-target { + outline: 2px dashed var(--accent); + outline-offset: 2px; +} + +.documents-panel .panel-section__header { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.documents-panel .panel-section__header .header-actions { + display: flex; + gap: 0.5rem; +} + +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + +.documents-panel .panel-section__body { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.panel-floating { + position: absolute; + top: calc(50% + 0.25rem); + left: 50%; + transform: translate(-50%, -50%); + background: color-mix(in oklch, var(--surface) 100%, transparent); + border: 1px solid color-mix(in oklch, var(--border) 95%, transparent); + padding: 0.45rem 0.85rem; + border-radius: 1rem; + font-size: 0.95rem; + font-weight: 400; + color: var(--fg); + box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent); + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + gap: 0.75rem; + z-index: 2000000; +} + +.panel-floating__label { + white-space: nowrap; + pointer-events: none; + font-size: 0.95rem; + color: var(--fg); +} + +.selection-summary { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.selection-summary__token { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.selection-summary__count { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.selection-summary__icon { + width: 1rem; + height: 1rem; +} + +.selection-summary--text { + font-weight: 600; +} + +.selection-summary__separator { + opacity: 0.45; +} + +.panel-floating-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: nowrap; + pointer-events: auto; +} + +.panel-floating-actions .quick-add { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +.panel-floating-actions__button { + display: inline-flex; + align-items: center; + gap: 0; + pointer-events: auto; + font-size: 1.35rem; +} + +.panel-floating-actions__button .icon-inline { + display: inline-flex; + width: 1.35rem; + height: 1.35rem; +} + +.documents-panel .documents-scroll { + overflow-y: auto; + background: transparent; + min-height: 0; + padding-right: 0.3rem; + flex-grow: 1; +} + +.documents-panel .documents-scroll:focus-visible { + outline: 2px solid var(--selection-ring); + outline-offset: 2px; + border-radius: 0.25rem; +} + +.documents-panel table { + max-width: 100%; + border-collapse: collapse; + font-size: 0.88rem; + margin-top: 0.4rem; +} + +.documents-panel thead th { + background-color: var(--surface); + position: sticky; + top: 0; + z-index: 1; + padding: 0.45rem 0.6rem; + color: var(--muted); +} + + +.documents-panel th, +.documents-panel td { + padding: 0.45rem 0.6rem; + text-align: left; +} + +.documents-panel--view-grid .documents-scroll { + padding: 0.35rem 0.5rem 1rem; +} + +.documents-panel th.thumb-column, +.documents-panel td.thumb-cell { + width: 54px; + text-align: center; +} + +.documents-panel--view-grid .document-thumbnail-wrapper { + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); + padding: 8px; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.documents-panel--view-grid .folder-card__icon { + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); + padding: 8px; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.document-thumbnail-inner { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.document-thumbnail-inner--multipage::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 24px; + height: 24px; + background-image: url('../../assets/papercorner.svg'); + background-repeat: no-repeat; + background-size: contain; + pointer-events: none; +} + +.documents-panel--view-grid .document-thumbnail-inner--multipage::after { + width: 28px; + height: 28px; +} + +.document-thumbnail { + max-width: 100%; + max-height: 100%; + box-shadow: 0 1px 6px var(--shadow-medium); +} + +.documents-panel--view-grid .document-thumbnail { + box-shadow: 0 1px 12px var(--shadow-medium); +} + +.thumb-placeholder { + width: 100%; + height: 100%; + border-radius: 1px; + background: var(--surface-subtle); + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; + box-shadow: 0 1px 3px var(--shadow-medium); +} + +.documents-panel--view-grid .thumb-placeholder { + font-size: 1.1rem; + letter-spacing: 0.12em; + display: inline-flex; + width: 100%; + height: 100%; +} + +.thumb-icon { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.thumb-icon__image { + width: 32px; + height: 32px; + display: block; +} + +.documents-panel th.actions-column, +.documents-panel td.actions { + width: 1%; + white-space: nowrap; +} + +.documents-panel td.doc-list__name { + width: 100%; +} + +.doc-title-edit { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; +} + +.doc-title-edit input[type='text'] { + padding: 0.3rem 0.55rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); + min-width: 8rem; +} + +.doc-title-edit input[type='text']:focus-visible { + outline: 2px solid var(--selection-ring); + outline-offset: 1px; +} + +.doc-title-edit .icon-button { + flex-shrink: 0; +} + +.documents-panel .doc-name__primary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.documents-panel .doc-name__primary-text { + overflow-wrap: anywhere; +} + + +.doc-entry { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.doc-entry--with-thumb .doc-entry__thumb { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.doc-entry__thumb .document-thumbnail-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.doc-entry__main { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.doc-entry__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.doc-entry__tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.documents-panel td.actions .action-buttons { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.documents-panel tbody tr { + background: transparent; + transition: background 0.15s ease; +} + +.documents-panel tbody tr.folder { + cursor: pointer; +} + +.documents-panel tbody tr.folder.focused { + box-shadow: inset 2px 0 0 var(--accent-outline); + background: var(--sidebar-hover-bg); +} + +.documents-panel tbody tr.document { + cursor: pointer; +} + +.documents-panel tbody tr.document.selected, +.documents-panel tbody tr.folder.selected { + background: var(--selection-soft); + box-shadow: inset 2px 0 0 var(--accent-outline-strong); +} + +.documents-panel tbody tr.document, +.documents-panel tbody tr.document * { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; +} + +.documents-panel tbody tr.document.focused:not(.selected) { + box-shadow: inset 2px 0 0 var(--accent-outline); +} + +.doc-name { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.documents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--documents-grid-icon-size), 1fr)); + gap: 0.3rem 1.3rem; + padding: 0; +} + +.document-card { + padding: 0rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + cursor: pointer; + align-items: center; + text-align: center; +} + +.document-card.selected .document-thumbnail-wrapper, +.folder-card.selected .folder-card__icon { + background-color: var(--selection-soft); +} + +.document-card:focus-visible .document-thumbnail-wrapper { + background-color: var(--selection-ring); +} + +.document-card.is-dragging { + opacity: 0.55; +} + +.document-card__meta { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + width: 100%; +} + +.document-card__title { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + color: var(--fg); +} + +.document-card__title-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.document-card__title-badge { + padding: 0.2rem 0.7rem; + border-radius: 1rem; + max-width: 100%; + word-break: break-word; + font-size: var(--documents-grid-title-size); + color: inherit; +} + +.document-card .doc-correspondent-link { + font-size: var(--documents-grid-title-size); +} + +.document-card.selected .document-card__title-badge { + background-color: var(--accent); + color: var(--on-accent); +} + +.document-card__subtitle { + font-size: 0.78rem; + color: var(--muted); +} + +.document-card__tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: center; +} + +.folder-card { + align-items: center; + text-align: center; +} + +.folder-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); +} + +.folder-card__icon-svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.folder-card__meta { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding-top: 0.35rem; +} + +.folder-card__label-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.folder-card__name { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: break-word; + font-size: var(--documents-grid-title-size); + padding: 0.2rem 0.7rem; + border-radius: 1rem; +} + +.folder-card__edit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.folder-card.selected .folder-card__name { + background-color: var(--accent); + color: var(--on-accent); +} + + +.doc-name__title { + max-width: 100%; + word-break: break-word; +} + +.doc-name__tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.doc-correspondents { + display: inline; + color: inherit; +} + +.doc-correspondent-link { + background: none; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--accent); + text-decoration: none; + font: inherit; + cursor: pointer; + box-shadow: none; + appearance: none; +} + +.doc-correspondent-link:not(.is-static):hover, +.doc-correspondent-link:not(.is-static):focus-visible { + color: var(--accent-strong, var(--accent)); + text-decoration: underline; + text-decoration-thickness: 1.5px; + background-color: transparent; +} + +.doc-correspondent-link:not(.is-static):focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.documents-panel tbody tr.document.selected .doc-correspondents, +.documents-panel tbody tr.document.selected .doc-correspondent-link, +.document-card.selected .doc-correspondent-link { + color: inherit; +} + +.document-card.selected .doc-correspondents { + color: inherit; +} + +.doc-correspondent-link.is-active { + font-weight: 600; +} + +.doc-correspondent-link.is-static { + color: inherit; + cursor: default; + text-decoration: none; +} + +.doc-correspondent-link__separator { + color: inherit; +} + +.documents-panel tbody tr.document.dragging { + opacity: 0.4; +} + +.documents-panel tbody tr.document.tag-drop-target { + background: var(--accent-soft); + box-shadow: inset 0 0 0 2px var(--accent); +} + +.document-card.tag-drop-target { + box-shadow: 0 0 0 2px var(--accent); + border-color: var(--accent); +} + +.document-card.tag-drop-target .document-card__title { + color: var(--accent); +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.32rem; + margin-bottom: 0.5rem; +} + +.filter-bar input[type='search'] { + flex: 1; + min-width: 180px; +} + +.filter-actions { + display: flex; + gap: 0.32rem; +} + +.search-hint { + margin-top: 0.75rem; + font-size: 0.85rem; + color: var(--muted); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.35rem; + border-radius: 2px; + background: var(--surface-subtle); + color: var(--fg); + font-size: 0.74rem; +} + +.tag-chip { + gap: 0.25rem; + border-radius: 1rem; + padding: 0.2rem 0.4rem; + font-weight: 600; +} + +.tag-chip--more { + background: transparent; + border-color: var(--border-strong); + color: var(--muted); +} + +.tag-chip--removable { + gap: 0.35rem; +} + +.tag-chip__remove { + background: none; + border: none; + color: inherit; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + cursor: pointer; + opacity: 0.8; +} + +.tag-chip__remove:hover, +.tag-chip__remove:focus-visible { + opacity: 1; +} + +.tag-chip__remove:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 50%; +} + +.empty-state { + border: 1px dashed var(--border); + border-radius: 0; + text-align: center; + padding: 1.1rem 0.9rem; + color: var(--muted); + margin-top: 0.75rem; + background: var(--surface-subtle); +} + +.empty-state--global { + border-style: solid; +} diff --git a/frontend/src/styles/documents/panel-sections.css b/frontend/src/styles/documents/panel-sections.css new file mode 100644 index 0000000..de07a4f --- /dev/null +++ b/frontend/src/styles/documents/panel-sections.css @@ -0,0 +1,133 @@ +.document-viewer__message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--on-accent); + background: var(--overlay-dark); + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.9rem; +} + +.panel-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0 0.5rem; +} + +.panel-section__titles { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.panel-section__header h2 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + flex-wrap: nowrap; + overflow: hidden; +} + +.panel-section__subtitle { + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--muted); +} + +.breadcrumb-trail { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; + min-width: 0; + flex: 1 1 auto; + max-width: 100%; + overflow: hidden; +} + +.breadcrumb-trail--measure { + position: absolute; + visibility: hidden; + pointer-events: none; + left: -9999px; + top: -9999px; + max-width: none; + overflow: visible; +} + +.breadcrumb-trail__link { + display: inline-flex; + align-items: center; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: none; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: default; +} + +.breadcrumb-trail__link:not(.is-current) { + cursor: pointer; + color: var(--accent); +} + +.breadcrumb-trail__link:not(.is-current):hover, +.breadcrumb-trail__link:not(.is-current):focus-visible { + text-decoration: underline; +} + +.breadcrumb-trail__separator { + color: var(--muted); + margin: 0 0.2rem; +} + +.breadcrumb-trail__ellipsis { + position: relative; + display: inline-flex; +} + +.breadcrumb-trail__ellipsis-button { + cursor: pointer; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + max-width: 22rem; +} + +.panel-section__body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel-section__body--scrollable, +.panel-section__body.scrollable { + overflow-y: auto; +} + diff --git a/frontend/src/styles/documents/shared-snippets.css b/frontend/src/styles/documents/shared-snippets.css new file mode 100644 index 0000000..e03be4e --- /dev/null +++ b/frontend/src/styles/documents/shared-snippets.css @@ -0,0 +1,26 @@ +.doc-list__name { + width: 100%; +} + +.doc-list__name-content { + display: inline-flex; + align-items: center; + gap: 0.22rem; + max-width: 100%; +} + +.doc-list__name-content span { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.preview-pane__nav-button svg { + width: 100%; + height: 100%; +} + +.panel-header .icon-button svg { + width: 1.41rem; + height: 1.41rem; +} diff --git a/frontend/src/styles/documents/tags-correspondents.css b/frontend/src/styles/documents/tags-correspondents.css new file mode 100644 index 0000000..9fd9b87 --- /dev/null +++ b/frontend/src/styles/documents/tags-correspondents.css @@ -0,0 +1,173 @@ +.tags-panel__body { + overflow-y: auto; +} + +.tags-table { + width: 100%; + overflow: auto; +} + +.tags-table table { + width: 100%; + border-collapse: collapse; + min-width: 320px; +} + +.tags-table th, +.tags-table td { + padding: 0.45rem 0.6rem; + text-align: left; + font-size: 0.85rem; +} + +.tags-table th.numeric, +.tags-table td.numeric { + text-align: right; +} + +.tags-table th.actions, +.tags-table td.actions { + text-align: right; + width: 0; +} + +.tags-table tbody tr:hover { + background: var(--surface-subtle); +} + +.tags-table tr.editing { + background: var(--sidebar-hover-bg); +} + +.tags-table__label { + max-width: 24rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tags-table__swatch { + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 2px; + box-shadow: inset 0 0 0 1px var(--shadow-faint); +} + +.tags-panel__error { + margin: 0.5rem 0; + color: var(--danger); + font-size: 0.8rem; +} + +.tags-table__label-input { + width: 100%; +} + +.tags-table__color-editor { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.tags-table__color-picker { + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: none; + cursor: pointer; +} + +.tags-table__edit-controls { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.4rem; +} + +.tags-table__row-actions { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.4rem; +} + +.correspondents-panel .header-actions { + gap: 0.5rem; +} + +.tags-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.tags-actions__form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.tags-actions__form input[type='text'] { + min-width: 14rem; +} + +.correspondents-actions__form { + display: flex; + gap: 0.4rem; +} + +.correspondents-actions__form input { + min-width: 14rem; +} + + + +.correspondent-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: var(--surface-subtle); + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.9rem; + border: none; +} + +.correspondent-pill__label { + line-height: 1.2; +} + +.correspondent-pill__label strong { + font-size: 0.8rem; + text-transform: capitalize; + color: var(--muted); +} + +.correspondent-pill__remove { + border: none; + background: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted); + padding: 0; +} + +.correspondent-pill__remove:hover { + color: var(--danger); +} + +.correspondent-form { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.correspondent-form input, +.correspondent-form select { + min-height: 32px; +} diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css new file mode 100644 index 0000000..74af8df --- /dev/null +++ b/frontend/src/styles/documents/viewer.css @@ -0,0 +1,335 @@ +.document-viewer { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 30em) minmax(0, 1fr); + gap: 1rem; + min-height: 0; + padding: 1rem 1rem; +} + +.document-drag-preview { + position: fixed; + pointer-events: none; + top: -9999px; + left: -9999px; + width: var(--drag-preview-size, 96px); + height: var(--drag-preview-size, 96px); + z-index: 9999; +} + +.document-drag-preview__thumb { + position: absolute; + top: 50%; + left: 50%; + width: 64px; + height: 64px; + border-radius: 6px; + box-shadow: 0 6px 12px var(--shadow-pop); + overflow: hidden; + background-color: var(--overlay-dim); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg)); + transform-origin: center; +} + +.document-drag-preview__thumb--image { + background-color: #000; + background-repeat: no-repeat; + background-size: contain; + background-position: center; +} + +.document-drag-preview__thumb .document-thumbnail, +.document-drag-preview__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.document-drag-preview__thumb .thumb-placeholder, +.document-drag-preview__thumb .thumb-placeholder * { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb svg { + width: 48px; + height: 48px; + color: var(--accent-strong, var(--accent)); +} + +.document-drag-preview__folder-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-on-dark); +} + +.document-drag-preview__count { + position: absolute; + bottom: 4px; + right: 4px; + background-color: var(--accent); + color: var(--on-accent); + border-radius: 999px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + box-shadow: 0 4px 8px var(--shadow-pop); + pointer-events: none; +} + +.document-viewer__details { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + flex: 1; +} + +.document-viewer__details-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + flex: 1; +} +.document-viewer__tabs-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.document-viewer__section { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding-top: 1rem; +} + +.document-viewer__section-title { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.document-viewer__section-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem 1.25rem; +} + +.document-viewer__section-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.document-viewer__section-item dt { + font-size: 0.75rem; + color: var(--muted); +} + +.document-viewer__section-item dd { + margin: 0; + font-size: 0.9rem; + font-weight: 500; + word-break: break-word; +} + +.document-viewer__section-placeholder { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.document-viewer__section-payload { + font-size: 0.85rem; +} + +.document-viewer__section-payload summary { + cursor: pointer; + font-weight: 500; + color: var(--accent-strong, var(--accent)); +} + +.document-viewer__section-payload pre { + margin: 0.75rem 0 0; + padding: 0.75rem; + background: var(--surface); + max-height: 280px; + overflow: auto; + font-size: 0.8rem; +} + +.document-viewer__section--metadata-json { + overflow: auto; +} + +.document-viewer__metadata-json { + margin: 0; + padding: 0.75rem; + background: var(--surface); + border-radius: 0.5rem; + font-size: 0.85rem; + line-height: 1.35; + overflow: auto; +} + +.document-viewer__tabs { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--outline-subtle); +} + +.document-viewer__tab { + appearance: none; + border: none; + background: transparent; + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: + color 120ms ease, + border-color 120ms ease; +} + +.document-viewer__tab:hover, +.document-viewer__tab:focus-visible { + color: var(--accent); +} + +.document-viewer__tab.is-active { + color: var(--accent); + border-color: var(--accent); +} + +.document-viewer__tabpanes { + flex: 1; + min-height: 0; + display: flex; +} + +.document-viewer__tabpanes--single { + flex: 1; + min-height: 0; +} + +.document-viewer__tabpanel { + flex: 1; + min-height: 0; + display: flex; + position: relative; +} + +.document-viewer__object--ocr { + width: 100%; + height: 100%; +} + +.document-viewer__object--ocr-text { + width: 100%; + height: 100%; + margin: 0; + padding: 1rem 0; + font-size: 1rem; + white-space: pre-wrap; + font-family: inherit; +} + +.document-viewer__message--error { + color: var(--danger); +} + +.document-viewer__viewport { + flex: 1; + min-width: 0; + display: flex; + position: relative; + overflow: hidden; + align-items: flex-start; +} + +.document-viewer__object { + width: 100%; + height: 100%; + border: none; +} + +.document-viewer__object--image { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + align-self: flex-start; +} + +.document-viewer__unsupported { + height: 100%; + width: 100%; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + text-align: center; + background: var(--surface-subtle); +} + +.document-viewer__unsupported-filename { + font-size: 0.9rem; + color: var(--muted); +} + +.document-viewer__unsupported-message { + font-size: 0.95rem; + color: var(--fg); +} + +.document-viewer__unsupported-download { + font-weight: 600; +} + +.document-viewer__unsupported-download svg { + width: 1rem; + height: 1rem; +} + diff --git a/frontend/src/styles/forms/elements.css b/frontend/src/styles/forms/elements.css new file mode 100644 index 0000000..d1c8ad1 --- /dev/null +++ b/frontend/src/styles/forms/elements.css @@ -0,0 +1,46 @@ +select { + font: inherit; + border-radius: 2px; + border: 1px solid var(--border); + padding: 0.4rem 0.5rem; + background: var(--surface); + color: inherit; + width: 100%; + box-sizing: border-box; +} + +input[type='text'], +input[type='email'], +input[type='password'], +input[type='search'], +input[type='number'], +input[type='url'], +input[type='tel'] { + font: inherit; + border-radius: 2px; + border: 1px solid var(--border); + padding: 0.4rem 0.5rem; + background: var(--surface); + color: inherit; + width: 100%; + box-sizing: border-box; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); + background: var(--surface); +} + +textarea { + resize: vertical; +} + +form.inline { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..884e2fd --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,22 @@ +@import './base/theme.css'; +@import './base/controls.css'; +@import './layout/panel-header-controls.css'; +@import './preview/preview-zoom.css'; +@import './base/iconography.css'; +@import './documents/controls.css'; +@import './layout/structure.css'; +@import './documents/viewer.css'; +@import './documents/tags-correspondents.css'; +@import './documents/panel-sections.css'; +@import './sidebar/sidebar.css'; +@import './documents/listing.css'; +@import './detail/detail-panels.css'; +@import './forms/elements.css'; +@import './modals/status-drop.css'; +@import './settings/settings.css'; +@import './modals/panel-modals.css'; +@import './layout/responsive.css'; +@import './auth/login.css'; +@import './documents/shared-snippets.css'; +@import './uploads/overlay.css'; +@import './base/text-button.css'; diff --git a/frontend/src/styles/layout/panel-header-controls.css b/frontend/src/styles/layout/panel-header-controls.css new file mode 100644 index 0000000..7846e7c --- /dev/null +++ b/frontend/src/styles/layout/panel-header-controls.css @@ -0,0 +1,43 @@ +.panel-header .icon-button, +.panel-header button, +.panel-header a.icon-button { + display: inline-flex; + align-items: center; + justify-content: flex-start; + border: none; + background: transparent; + color: var(--muted); + padding: 0.25rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + text-decoration: none; + text-align: left; +} + +.panel-header .icon-button:hover:not([disabled]), +.panel-header button:hover:not([disabled]), +.panel-header a.icon-button:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.panel-header .icon-button.active:hover:not([disabled]), +.panel-header button.active:hover:not([disabled]), +.panel-header a.icon-button.active:hover { + background: var(--accent-soft); + color: var(--fg); +} + +.panel-header .icon-button.ghost, +.panel-header button.icon-button.ghost { + color: var(--muted); + padding: 0.25rem; +} + +.panel-header .icon-button.ghost:hover:not([disabled]), +.panel-header button.icon-button.ghost:hover:not([disabled]) { + color: var(--fg); + background: var(--sidebar-hover-bg); +} + diff --git a/frontend/src/styles/layout/responsive.css b/frontend/src/styles/layout/responsive.css new file mode 100644 index 0000000..a3195d5 --- /dev/null +++ b/frontend/src/styles/layout/responsive.css @@ -0,0 +1,17 @@ +@media (max-width: 768px) { + .app-bar { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + .app-main { + grid-template-columns: 1fr; + } + .sidebar, + .documents-panel, + .detail-panel, + .tags-panel, + .correspondents-panel { + min-height: auto; + } +} diff --git a/frontend/src/styles/layout/structure.css b/frontend/src/styles/layout/structure.css new file mode 100644 index 0000000..4f07e1e --- /dev/null +++ b/frontend/src/styles/layout/structure.css @@ -0,0 +1,147 @@ +.app-shell { + height: 100%; + display: flex; + flex-direction: column; + color: var(--fg); +} + + +.documents-main { + flex: 1; + display: flex; + min-height: 100vh; + width: 100%; + position: relative; +} + +.documents-main--sidebar-collapsed { + position: relative; +} + +.main-content { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + margin: 0; + border-radius: 0; + background: var(--surface); + flex-grow: 1; + box-shadow: -12px 0 24px -12px var(--shadow-faint); +} + +.documents-main:not(.documents-main--sidebar-collapsed) .main-content { + border-left: 1px solid var(--border); +} + +.main-content__header { + padding: 0.5rem 1.25rem; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +.main-content__header-wrapper { + position: relative; +} + +.main-content__body { + position: relative; + flex: 1 1 auto; + display: flex; + min-height: 0; +} + +.main-content__body > * { + min-width: 0; + min-height: 0; +} + +.main-content__body > *:not(.detail-panel) { + flex: 1 1 auto; +} + +.main-content__body--workspace.main-content__body--has-detail > .desk-shell { + margin-right: calc(-1 * var(--detail-panel-width)); +} + +.main-content--has-detail { + padding-right: var(--detail-panel-width); +} + +.main-content__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fg); +} + + +.documents-main--sidebar-collapsed { + position: relative; + grid-template-columns: auto minmax(0, 1fr); +} + +.sidebar .panel-header .icon, +.main-content__header .icon, +.detail-panel .panel-header .icon { + width: 1.35rem; + height: 1.35rem; +} + +.main-content__actions { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.main-content__actions-divider { + display: inline-flex; + align-items: center; + color: var(--muted); +} + +.main-content__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fg); + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.main-content__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + +.main-content__subtitle { + font-size: 0.9rem; + color: var(--muted); + font-weight: 400; + line-height: 1.2; + flex-shrink: 0; +} + +.panels-main { + flex: 1; + display: flex; + justify-content: center; + padding: 1.5rem; + overflow: auto; +} + +.panels-main > * { + flex: 0 1 720px; +} + +.preview-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} diff --git a/frontend/src/styles/modals/panel-modals.css b/frontend/src/styles/modals/panel-modals.css new file mode 100644 index 0000000..67f6791 --- /dev/null +++ b/frontend/src/styles/modals/panel-modals.css @@ -0,0 +1,44 @@ +.panel-modal__header { + padding: 1rem 1rem 1rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.panel-modal__body { + flex: 1; + overflow: auto; + padding: 0; +} + +.modal__body { + width: 100%; +} + +.modal h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} + +.modal__form { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.modal__form label { + font-weight: 600; + font-size: 0.9rem; +} + +.modal__error { + margin: -0.45rem 0 0; + font-size: 0.85rem; + color: var(--danger); +} + +.modal__actions { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.6rem; +} diff --git a/frontend/src/styles/modals/status-drop.css b/frontend/src/styles/modals/status-drop.css new file mode 100644 index 0000000..483213d --- /dev/null +++ b/frontend/src/styles/modals/status-drop.css @@ -0,0 +1,77 @@ +.status-banner { + padding: 0.45rem 0.8rem; + border-radius: 2px; + font-size: 0.85rem; +} + +.status-banner.info { + background: var(--surface-subtle); + color: var(--muted); +} + +.status-banner.success { + background: var(--success-subtle); + color: var(--success); +} + +.status-banner.error { + background: var(--danger-soft); + color: var(--danger); +} + +.drop-overlay { + position: fixed; + inset: 0; + background: var(--accent-soft); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.drop-overlay.active { + display: flex; +} + +.drop-overlay__content { + background: var(--surface); + color: var(--fg); + padding: 1.25rem 1.75rem; + border-radius: 2px; + text-align: center; + font-size: 0.95rem; + box-shadow: none; +} +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 2000000; +} + +.modal { + background: var(--surface); + border-radius: 4px; + box-shadow: 0 18px 42px var(--overlay-shadow); + width: min(360px, 100%); + padding: 1.6rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal--panel { + width: 80vw; + max-width: 960px; + max-height: 90vh; + padding: 0; + border-radius: 12px; + overflow: hidden; + gap: 0; +} + diff --git a/frontend/src/styles/preview/preview-zoom.css b/frontend/src/styles/preview/preview-zoom.css new file mode 100644 index 0000000..fc4e861 --- /dev/null +++ b/frontend/src/styles/preview/preview-zoom.css @@ -0,0 +1,113 @@ +.preview-zoom-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + transition: background 0.25s ease, opacity 0.25s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + z-index: 2000000; + cursor: zoom-out; + opacity: 0; + pointer-events: none; +} + +.preview-zoom-backdrop--visible { + opacity: 1; + background: var(--overlay-backdrop); + pointer-events: auto; +} + +.preview-zoom__stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + max-width: 95vw; + max-height: 95vh; + z-index: 3000000; +} + +.preview-zoom__image { + max-width: 95vw; + max-height: 95vh; + width: auto; + height: auto; +} + +.preview-zoom__scroll { + display: flex; + align-items: center; + justify-content: center; + max-width: 95vw; + max-height: 95vh; + box-shadow: 0 32px 120px var(--shadow-deep); +} + +.preview-zoom__scroll:focus { + outline: none; +} + +.preview-zoom__scroll--native { + overflow: auto; + cursor: zoom-out; + justify-content: flex-start; + align-items: flex-start; +} + +.preview-zoom__nav { + position: absolute; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.9rem; + opacity: 0; + transition: opacity 0.18s ease; + pointer-events: none; +} + +.preview-zoom__stage:hover .preview-zoom__nav, +.preview-zoom__stage:focus-within .preview-zoom__nav { + opacity: 1; + pointer-events: auto; +} + +.preview-zoom__nav-button { + width: 2.8rem; + height: 2.8rem; + border: none; + border-radius: 999px; + background: var(--preview-nav-bg); + color: var(--preview-nav-fg); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; + transition: background 0.15s ease, transform 0.15s ease; + box-shadow: 0 12px 28px var(--shadow-strong); +} + +.preview-zoom__nav-button:hover { + background: var(--preview-nav-bg-hover); +} + +.preview-zoom__nav-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.preview-zoom__nav-button svg { + width: 1.5rem; + height: 1.5rem; +} + +.preview-zoom__nav-button[disabled] { + opacity: 0.35; + cursor: default; +} + diff --git a/frontend/src/styles/settings/settings.css b/frontend/src/styles/settings/settings.css new file mode 100644 index 0000000..1714e2c --- /dev/null +++ b/frontend/src/styles/settings/settings.css @@ -0,0 +1,337 @@ +.settings-modal__body { + display: grid; + grid-template-columns: 12rem 1fr; + gap: 1.5rem; + height: 90vh; + padding: 1.5rem; +} + +.settings-modal__sidebar { + border-right: 1px solid var(--border-muted, var(--border)); + padding-right: 1rem; +} + +.settings-modal__sidebar ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.settings-modal__sidebar button { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 0.4rem; + border: none; + background: none; + color: inherit; + font: inherit; + padding: 0.45rem 0.6rem; + border-radius: 0.35rem; + cursor: pointer; +} + +.settings-modal__sidebar button:hover, +.settings-modal__sidebar button:focus-visible { + background: var(--sidebar-hover-bg); +} + +.settings-modal__sidebar button.active { + background: var(--accent-soft); + font-weight: 600; +} + +.settings-modal__content { + overflow-y: auto; + padding-bottom: 1rem; +} + +.settings-section h4 { + margin-top: 0; +} + +.settings-section { + display: flex; + flex-direction: column; +} + +.settings-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.settings-actions .secondary { + min-width: 7rem; +} + +.settings-form { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + margin-bottom: 0.75rem; +} + +.settings-form__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 14rem; +} + +.settings-form__field--full { + flex: 1 1 100%; + min-width: 100%; +} + +fieldset.settings-form__field { + border: 1px solid var(--border-muted, var(--border)); + border-radius: 0.5rem; + padding: 0.75rem 0.9rem 0.85rem; + background: var(--surface-soft); +} + +fieldset.settings-form__field legend { + padding: 0 0.35rem; + font-weight: 600; + color: var(--muted-strong, inherit); +} + +.settings-form__field input[type='text'], +.settings-form__field input[type='datetime-local'] { + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + background: var(--surface-soft); + color: inherit; +} + +.settings-form__choices { + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-start; +} + +.settings-capability-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.capability-dropdown { + position: relative; + width: 100%; +} + +.capability-dropdown__trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--surface-soft); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.capability-dropdown__trigger:hover:not(:disabled), +.capability-dropdown__trigger:focus-visible { + border-color: var(--accent); + background: var(--surface); + outline: none; +} + +.capability-dropdown__trigger:disabled { + cursor: not-allowed; + color: var(--muted); + background: var(--surface-muted, var(--surface-soft)); +} + +.capability-dropdown__chevron { + flex-shrink: 0; + opacity: 0.8; +} + +.capability-dropdown__menu { + margin-top: 0.35rem; + max-height: 18rem; + overflow-y: auto; + padding: 0.25rem 0; + width: 100%; +} + +.capability-dropdown__option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; +} + +.capability-dropdown__option-icon { + width: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon { + color: transparent; +} + +.capability-dropdown__option-label { + flex: 1; + text-align: left; +} + +.capability-dropdown__empty { + padding: 0.6rem 0.8rem; + color: var(--muted); +} + +.settings-capability-picker__chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.settings-capability-picker__chips--inline { + margin-top: 0.35rem; +} + +.settings-capabilities-summary { + display: inline-block; + margin-top: 0.4rem; + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-picker__placeholder { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.settings-capability-list--compact { + margin-top: 0.2rem; + gap: 0.25rem; +} + +.settings-capability-list__item { + display: inline-flex; + align-items: center; +} + +.settings-choice { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + font-weight: 500; + color: inherit; +} + +.settings-choice input[type='checkbox'] { + width: 1.05rem; + height: 1.05rem; + cursor: pointer; + accent-color: var(--accent); +} + +.settings-choice input[type='checkbox']:disabled + span { + color: var(--muted); +} + +.settings-form__actions { + display: flex; + gap: 0.5rem; +} + +.settings-form__error { + color: var(--danger); + font-size: 0.85rem; + margin: 0 0 0.75rem; +} + +.settings-empty { + margin: 1rem 0; + color: var(--muted); +} + +.settings-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.settings-table th, +.settings-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border-muted, var(--border)); + text-align: left; + font-size: 0.9rem; +} + +.settings-table tr:last-child td { + border-bottom: none; +} + +.settings-table__actions { + white-space: nowrap; +} + +.settings-status { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-table tr.is-revoked { + opacity: 0.65; +} + +.settings-notice { + border: 1px solid var(--accent); + background: var(--accent-soft); + padding: 0.9rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.token-display { + background: var(--surface-ink-soft); + padding: 0.5rem 0.65rem; + border-radius: 0.35rem; + font-family: var(--font-mono); + font-size: 0.95rem; + overflow-x: auto; +} + +.settings-notice__actions { + display: flex; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.settings-notice__actions button { + flex: 0 0 auto; +} + diff --git a/frontend/src/styles/sidebar/sidebar.css b/frontend/src/styles/sidebar/sidebar.css new file mode 100644 index 0000000..2adf0bd --- /dev/null +++ b/frontend/src/styles/sidebar/sidebar.css @@ -0,0 +1,655 @@ +.folder-tree { + list-style: none; + margin: 0; + padding: 0; +} + +.folder-node { + margin: 0; +} + +.folder-row { + display: flex; + align-items: center; + gap: 0.22rem; + padding: 0.32rem 0.48rem; + border-radius: 8px; + cursor: pointer; + color: inherit; + transition: background 0.12s ease, color 0.12s ease; + position: relative; + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +.folder-row .name-wrap { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 1; + min-width: 0; +} + +.folder-row .name-wrap .name__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-row .folder-icon-image { + flex-shrink: 0; +} + +.folder-row:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.folder-row.active { + background: var(--sidebar-active-bg); + color: var(--fg); +} + +.folder-row .toggle { + width: 1.05rem; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + flex-shrink: 0; + transition: transform 0.18s ease; +} + +.folder-row .toggle.invisible { + visibility: hidden; +} + +.toggle-icon { + width: 0.9rem; + height: 0.9rem; + transition: transform 0.18s ease; +} + +.folder-row .toggle.expanded .toggle-icon { + transform: rotate(90deg); +} + +.folder-row__actions { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + gap: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + background-color: var(--surface-overlay); + border-radius: 8px; + padding: 0 0.18rem; +} + +.folder-row__actions .icon-button { + margin: 0; +} + +.folder-row:hover .folder-row__actions, +.folder-row:focus-within .folder-row__actions { + opacity: 1; + pointer-events: auto; +} + +.folder-children { + list-style: none; + margin: 0 0 0 0.45rem; + padding: 0; +} + + + +.sidebar-section { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.sidebar { + gap: 0; + overflow: hidden; + color: var(--sidebar-fg); + display: flex; + flex-direction: column; + width: 20em; + max-width: 20em; + flex: 0 0 20em; + background: var(--bg); +} + +.sidebar__body { + flex: 1; + display: flex; + flex-direction: column; + padding: 0.5rem 1rem; + gap: 0.75rem; + overflow-y: auto; + min-height: 0; +} + +.sidebar__footer { + margin-top: auto; + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--border); + background: var(--surface-subtle); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sidebar__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fg); + padding-left: 0.25rem; +} + +.sidebar__tenant { + color: var(--muted); + font-weight: 500; +} + +.sidebar__header { + position: relative; +} + +.sidebar__title-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; +} + +.sidebar__title-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar__collapse-button { + color: color-mix(in oklch, var(--muted) 85%, transparent); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} + +.sidebar:hover .sidebar__collapse-button, +.sidebar__collapse-button:focus-visible { + opacity: 0.85; + pointer-events: auto; +} + +.sidebar__collapse-button:hover:not([disabled]), +.sidebar__collapse-button:focus-visible { + color: var(--muted); +} + + +.sidebar__title-button .sidebar__title-chevron { + opacity: 0; + transform: translateY(-1px); + transition: + opacity 0.12s ease, + transform 0.2s ease; +} + +.sidebar__title-button:hover .sidebar__title-chevron, +.sidebar__title-button:focus-visible .sidebar__title-chevron, +.sidebar__title-chevron.is-open { + opacity: 1; +} + +.sidebar__title-chevron.is-open { + transform: rotate(180deg) translateY(-1px); +} + +.menu { + position: absolute; + top: 100%; + left: 1em; + right: auto; + background: var(--surface); + border: 1px solid var(--border-muted, var(--border)); + border-radius: 0.5rem; + box-shadow: 0 12px 28px var(--shadow-strong); + min-width: 220px; + z-index: 20; + overflow: hidden; +} + +.menu__list { + max-height: 260px; + overflow-y: auto; + padding: 0.35rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.menu__heading { + padding: 0.5rem 1rem 0.25rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--muted); +} + +.menu__heading--with-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.menu button.menu__item, +.menu .menu__item { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.75rem; + border: none; + border-radius: 0.4rem; + background: none; + color: var(--sidebar-fg); + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu button.menu__item:hover, +.menu button.menu__item:focus-visible, +.menu .menu__item:hover, +.menu .menu__item:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.menu button.menu__item.active, +.menu .menu__item.active { + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); +} + +.menu button.menu__item:focus-visible, +.menu .menu__item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.menu__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu__check-slot { + width: 1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.menu__active-indicator { + font-size: 0.75rem; + color: var(--muted); +} + +.menu__empty { + display: block; + padding: 0.6rem 0.75rem; + color: var(--muted); + font-size: 0.85rem; +} + +.menu__footer { + border-top: 1px solid var(--border-muted, var(--border)); + padding: 0.35rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.menu--simple .menu__footer { + border-top: none; +} + + +.menu__section { + border-top: 1px solid var(--border); + padding: 0.35rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.menu__heading-actions { + display: inline-flex; + gap: 0.35rem; +} + +.menu__slider { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--muted); + padding: 0 1rem 0.25rem; +} + +.menu__section > .menu__slider { + margin-top: -0.25rem; +} + +.menu__slider input[type='range'] { + flex: 1; + accent-color: var(--accent); +} + +.menu__slider-label { + min-width: 2.5rem; +} + +.menu__slider-value { + font-variant-numeric: tabular-nums; + color: var(--fg); + min-width: 3rem; + text-align: right; +} + +.menu__button { + width: 100%; + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0.4rem 0.5rem; + border-radius: 0.35rem; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu__button:hover, +.menu__button:focus-visible { + background: var(--sidebar-hover-bg); +} + +.menu__button--danger { + color: var(--danger); +} + +.menu__button--danger:hover, +.menu__button--danger:focus-visible { + background: var(--surface-danger-subtle); + color: var(--danger); +} + +.sidebar__search { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0 0 0.75rem; +} + +.sidebar__search input[type='search'] { + flex: 1; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); +} + +.sidebar__search button { + padding: 0.35rem 0.75rem; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; +} + +.sidebar__search button:hover:not([disabled]) { + background: var(--sidebar-hover-bg); +} + +.sidebar__footer .sidebar-section { + margin-top: 0; +} + +.sidebar__footer .sidebar-section__actions { + gap: 0.4rem; +} + +.sidebar-section:first-of-type, +.sidebar-section--folders { + margin-top: 0; +} + +.sidebar-section--folders { + display: flex; + flex-direction: column; +} + +.sidebar-section__header { + display: flex; + align-items: center; + font-size: 0.75rem; + justify-content: space-between; + color: var(--muted); +} + +.sidebar-section__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + width: 100%; + background: transparent; + border: none; + border-radius: 6px; + padding: 0.25rem 0.35rem; + margin: -0.25rem -0.35rem; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; +} + +.sidebar-section__title:hover:not([disabled]), +.sidebar-section__title:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-section__title:hover:not([disabled]) h3, +.sidebar-section__title:focus-visible h3 { + color: var(--fg); +} + +.sidebar-section__header h3 { + margin: 0; + font-weight: 600; +} + +.sidebar-section__actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.sidebar-section__actions .meta { + font-size: 0.75rem; + color: var(--muted); +} + +.sidebar-slider { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--muted); +} + +.sidebar-slider input[type='range'] { + flex: 1; + accent-color: var(--accent); +} + +.sidebar-slider__value { + min-width: 3rem; + text-align: right; + color: var(--fg); + font-variant-numeric: tabular-nums; +} + +.sidebar-item { + background: transparent; + padding: 0.16rem 0.26rem; + text-align: left; + color: var(--sidebar-fg); + border-radius: 2px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; + display: flex; + align-items: center; + gap: 0.38rem; + width: 100%; +} + +.sidebar-item:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-item.active { + background: var(--sidebar-active-bg); + color: var(--fg); + font-weight: 600; +} + +.sidebar-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-item__icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--accent); +} + +.sidebar-item.active .sidebar-item__icon { + color: currentColor; +} + +.sidebar-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.25rem 0 0.1rem; +} + +.sidebar-tag-pill { + border-radius: 999px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + background: var(--surface-soft); + color: var(--fg); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, + opacity 0.12s ease; + box-shadow: 0 1px 2px var(--shadow-faint); +} + +.sidebar-tag-pill:hover { + transform: none; + box-shadow: 0 2px 6px var(--shadow-pop); + border-color: var(--accent-soft); +} + +.sidebar-tag-pill:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-tag-pill.active { + border-color: var(--sidebar-active-pill-border); + box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border); +} + +.sidebar-tag-pill--untagged { + border: 1px dashed var(--border); + background: var(--surface-subtle); + color: var(--muted); +} + +.sidebar-tag-pill--untagged.active { + color: var(--fg); +} + +.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) { + opacity: 0.45; +} + +.sidebar-correspondent-list { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-correspondent-item { + display: block; + background: transparent; + border-radius: 4px; + padding: 0.35rem 0.5rem; + text-align: left; + color: var(--sidebar-fg); + cursor: pointer; + font-size: 0.85rem; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.sidebar-correspondent-item:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-correspondent-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-correspondent-item.active { + background: var(--sidebar-active-bg); + color: var(--fg); +} diff --git a/frontend/src/styles/uploads/overlay.css b/frontend/src/styles/uploads/overlay.css new file mode 100644 index 0000000..640432c --- /dev/null +++ b/frontend/src/styles/uploads/overlay.css @@ -0,0 +1,176 @@ +.upload-queue-overlay { + position: fixed; + bottom: 24px; + right: 24px; + width: min(360px, calc(100vw - 32px)); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow-pop); + z-index: 400; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.9rem; + color: var(--fg); +} + + +.upload-queue-overlay__header { + padding: 0; + border: none; +} + +.upload-queue-overlay__title { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + font-size: 0.95rem; + min-width: 0; +} + +.upload-queue-overlay__summary { + font-size: 0.8rem; + color: var(--muted); + white-space: nowrap; +} + +.upload-queue-overlay__controls { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.upload-queue-overlay__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.upload-queue-overlay--collapsed .upload-queue-overlay__list { + display: none; +} + +.upload-queue-overlay__item { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.6rem; + padding: 0.15rem 0; +} + +.upload-queue-overlay__status { + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface-subtle); + color: var(--muted); +} + +.upload-queue-overlay__status--muted { + background: var(--surface-subtle); + color: var(--muted); +} + +.upload-queue-overlay__status--accent { + background: var(--accent-soft); + color: var(--accent); +} + +.upload-queue-overlay__status--success { + background: var(--success-subtle); + color: var(--success); +} + +.upload-queue-overlay__status--info { + background: var(--selection-soft); + color: var(--accent); +} + +.upload-queue-overlay__status--danger { + background: var(--danger-subtle); + color: var(--danger); +} + +.upload-queue-overlay__details { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.upload-queue-overlay__name { + font-size: 0.88rem; + font-weight: 500; + color: var(--fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-queue-overlay__name-link { + font-size: 0.88rem; + font-weight: 500; + color: inherit; + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + text-decoration: none; +} + +.upload-queue-overlay__name-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.upload-queue-overlay__meta-line { + font-size: 0.72rem; + color: var(--muted); + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.upload-queue-overlay__meta-id, +.upload-queue-overlay__meta-secondary { + color: inherit; +} + +.upload-queue-overlay__meta-link { + border: none; + background: none; + font: inherit; + cursor: pointer; + padding: 0; + color: inherit; + text-decoration: none; +} + +.upload-queue-overlay__meta-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.upload-queue-overlay__meta-link:disabled { + cursor: default; + opacity: 0.5; +} + +.upload-queue-overlay__meta-error { + color: var(--danger); +} + +.upload-queue-overlay__item--error .upload-queue-overlay__name { + color: var(--danger); +} diff --git a/frontend/src/styles/workspace/workspace-cards.css b/frontend/src/styles/workspace/workspace-cards.css new file mode 100644 index 0000000..4079627 --- /dev/null +++ b/frontend/src/styles/workspace/workspace-cards.css @@ -0,0 +1,122 @@ +/* Workspace cards, thumbnails, and hover controls */ +.desk-item__shadow { + display: none; +} + +.desk-item__card { + position: relative; + border-radius: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-shadow: 0 12px 32px var(--shadow-medium); + overflow: hidden; +} + +.desk-item__card img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.desk-item__card--empty { + box-shadow: 0 12px 32px var(--shadow-medium); + background: + radial-gradient(circle at 42% 38%, color-mix(in oklch, var(--surface-subtle) 75%, var(--selection) 25%), color-mix(in oklch, var(--surface-subtle) 85%, var(--selection) 15%) 70%), + linear-gradient(135deg, color-mix(in oklch, var(--surface-subtle) 88%, var(--selection-soft) 12%) 0%, color-mix(in oklch, var(--surface-subtle) 65%, var(--shadow-faint) 35%) 100%); + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.desk-item__placeholder { + font-size: 0.95rem; + font-weight: 500; + letter-spacing: normal; + color: var(--muted); +} + +.desk-item__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 0.5rem; + padding: 1rem; + text-align: center; +} + +.desk-item__title { + font-size: 0.95rem; + font-weight: 500; + color: var(--fg); + max-width: 90%; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.desk-card__nav { + position: absolute; + bottom: 1.8rem; + left: 50%; + transform: translateX(-50%); + transform-origin: center; + display: flex; + gap: 1.5rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.desk-item__card:hover .desk-card__nav { + opacity: 1; + pointer-events: auto; +} + +.desk-card__nav-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4em; + height: 2.4em; + padding: 0.25em; + border-radius: 50%; + border: none; + background: var(--preview-nav-bg); + color: var(--preview-nav-fg); + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.desk-card__nav-button:hover:not([disabled]) { + background: var(--preview-nav-bg-hover); +} + +.desk-card__nav-button:disabled { + opacity: 0.4; + cursor: default; +} + +.desk-card__nav-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.desk-card__nav-button svg { + width: 100%; + height: 100%; +} diff --git a/frontend/src/styles/workspace/workspace-items.css b/frontend/src/styles/workspace/workspace-items.css new file mode 100644 index 0000000..962d1d8 --- /dev/null +++ b/frontend/src/styles/workspace/workspace-items.css @@ -0,0 +1,135 @@ +/* Workspace items, states, and inline badges */ +.desk-item { + position: absolute; + display: block; + width: auto; + cursor: grab; + touch-action: none; + transform-origin: center center; + transition: box-shadow 0.16s ease; + outline: none; + will-change: transform; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +.desk-item__body { + flex-grow: 1; + width: 100%; + height: 100%; +} + +.desk-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} + +.desk-item.is-dragging { + cursor: grabbing; + transition: none; +} + +.desk-item.is-tag-target .desk-item__card { + outline: 0.35rem dashed var(--accent); + outline-offset: 0.35rem; +} + +.desk-item.is-tag-pending .desk-item__card { + outline: 0.25rem solid var(--accent-outline); + outline-offset: 0.25rem; +} + +.desk-item.is-filtered-out { + opacity: 0.12; + pointer-events: none; + filter: blur(15px) grayscale(100%); + transition: opacity 0.6s ease, filter 0.28s ease; + z-index: 0; +} + +.desk-item.is-selected { + z-index: 5; +} + +.desk-item.is-selected .desk-item__card { + box-shadow: + 0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent), + 0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent), + 0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent), + 0 10px 24px var(--shadow-medium); +} + +.desk-item.is-selected .desk-item__title { + color: var(--accent); +} + +.desk-item__tags { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-end; + transform-origin: top right; + transform: translate(-0.5em, 0.5em); + transition: transform 0.28s ease; +} + +.desk-item__correspondents { + position: absolute; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-start; + transform-origin: bottom left; + transform: translate(0.5em, -0.5em); + pointer-events: none; +} + +.desk-correspondent-chip { + pointer-events: none; + font-size: 0.82rem; + padding: 0.18rem 0.55rem; + max-width: min(16rem, 80%); + display: inline-flex; + align-items: center; + overflow: hidden; + background: color-mix(in oklch, var(--surface-subtle) 90%, transparent); + color: var(--muted); +} + +.desk-correspondent-chip__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chip--draggable { + user-select: none; + pointer-events: auto; + cursor: grab; + transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; +} + +.tag-chip--draggable:active { + cursor: grabbing; +} + +.tag-chip--draggable.is-drag-hidden { + opacity: 0.4; +} + +.desk-item__tags .tag-chip { + font-size: 0.85rem; + padding: 0.18rem 0.55rem; + gap: 0.3rem; +} + +.desk-item__tags .tag-chip--tear-pending { + opacity: 0.35; +} diff --git a/frontend/src/styles/workspace/workspace-layout.css b/frontend/src/styles/workspace/workspace-layout.css new file mode 100644 index 0000000..223e693 --- /dev/null +++ b/frontend/src/styles/workspace/workspace-layout.css @@ -0,0 +1,45 @@ +/* Workspace layout & canvas scaffolding */ +.desk-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.desk-shell { + flex: 1; + display: flex; + flex-direction: column; + grid-column: 2 / -1; + min-height: 0; + position: relative; +} + +.desk-canvas { + flex: 1; + position: relative; + overflow: hidden; + margin: 0; + outline: none; +} + +.desk-canvas:focus, +.desk-canvas:focus-visible { + outline: none; +} + +.desk-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--muted); + font-size: 0.95rem; +} + +body.desk-cursor-remove, +body.desk-cursor-remove * { + cursor: not-allowed !important; +} diff --git a/frontend/src/ui/PanelHeader.jsx b/frontend/src/ui/PanelHeader.jsx index e7bad5f..5aa1e79 100644 --- a/frontend/src/ui/PanelHeader.jsx +++ b/frontend/src/ui/PanelHeader.jsx @@ -1,6 +1,5 @@ import React from 'react'; - -const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); +import composeClassName from './classNames'; const PanelHeader = ({ className = '', diff --git a/frontend/src/ui/classNames.js b/frontend/src/ui/classNames.js new file mode 100644 index 0000000..a9bd605 --- /dev/null +++ b/frontend/src/ui/classNames.js @@ -0,0 +1,3 @@ +export const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); + +export default composeClassName; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 6b4cb25..d8a8dfc 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -11,6 +11,7 @@ import { IconArrowRight, IconArrowUp, IconAnalyze, + IconUpload, IconWindowMaximize, IconTextScan2, IconFolderPlus, @@ -32,6 +33,9 @@ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconLayoutSidebarRightCollapse, + IconLayoutBottombarCollapse, + IconLayoutBottombarExpand, + IconClearAll, IconInfoCircle, IconCircleDashedCheck, IconFile, @@ -39,10 +43,10 @@ import { IconSortAscendingLetters, IconSortDescendingLetters, IconFileInfo, + IconAlertTriangle, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; - -const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); +import composeClassName from './classNames'; export const ChevronIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const UploadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const BottombarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const BottombarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const ClearAllIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const WarningIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => ( + +); + export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( Math.min(Math.max(value, min), max); - const resolveViewportWidth = () => { if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') { return window.innerWidth; @@ -198,12 +197,22 @@ const useFloatingMenu = ({ return undefined; } + let ignoreFocusEvents = true; + const rafId = typeof window !== 'undefined' + ? window.requestAnimationFrame(() => { + ignoreFocusEvents = false; + }) + : null; + if (!anchorRef?.current) { close(); return undefined; } const handlePointer = (event) => { + if (event.type === 'focusin' && ignoreFocusEvents) { + return; + } const menu = menuRef.current; const anchor = anchorRef?.current; if ((anchor && anchor.contains(event.target)) || (menu && menu.contains(event.target))) { @@ -223,6 +232,9 @@ const useFloatingMenu = ({ document.addEventListener('focusin', handlePointer); document.addEventListener('keydown', handleKeyDown); return () => { + if (rafId != null) { + window.cancelAnimationFrame(rafId); + } document.removeEventListener('mousedown', handlePointer); document.removeEventListener('touchstart', handlePointer); document.removeEventListener('focusin', handlePointer); diff --git a/frontend/src/utils/date.js b/frontend/src/utils/date.js new file mode 100644 index 0000000..955158a --- /dev/null +++ b/frontend/src/utils/date.js @@ -0,0 +1,57 @@ +const ensureDate = (value) => { + if (!value) { + return null; + } + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const formatDate = (value, { fallback = '—', locale, options } = {}) => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleDateString(locale, options); +}; + +export const formatDateTime = (value, { fallback = '—', locale, options } = {}) => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleString(locale, options); +}; + +export const toDateInputValue = (value) => { + const date = ensureDate(value); + if (!date) { + return ''; + } + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60000); + return localDate.toISOString().slice(0, 10); +}; + +export const toIssuedTimestamp = (dateString, fallback) => { + if (!dateString) { + return null; + } + const base = ensureDate(fallback) || new Date(); + const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); + if (!year || !month || !day) { + return null; + } + const candidate = new Date(base); + candidate.setUTCFullYear(year, month - 1, day); + return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString(); +}; + +export const parseDateValue = (value) => ensureDate(value); + +export default { + formatDate, + formatDateTime, + toDateInputValue, + toIssuedTimestamp, + parseDateValue, +}; diff --git a/frontend/src/utils/math.js b/frontend/src/utils/math.js new file mode 100644 index 0000000..f7ab48f --- /dev/null +++ b/frontend/src/utils/math.js @@ -0,0 +1,13 @@ +export const clamp = (value, min, max) => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; + +export default { + clamp, +};