Merge remote-tracking branch 'ui/ui' into dev

This commit is contained in:
2025-11-10 13:46:30 +01:00
63 changed files with 6861 additions and 6350 deletions
+11 -2
View File
@@ -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}
/>
<UploadQueueOverlay
queue={contextValue.uploadQueue || []}
onClearQueue={contextValue.clearUploadQueue}
/>
<Outlet />
{managementModals}
{settingsOpen ? (
<SettingsRoute open onClose={closeSettings} />
) : null}
</div>
</AppShellContext.Provider>
);
-2
View File
@@ -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 = () => (
<Routes>
@@ -13,7 +12,6 @@ const AppRouter = () => (
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="/settings" element={<SettingsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
</Route>
</Routes>
+8 -17
View File
@@ -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 (
<SettingsModal
open
open={open}
onClose={handleClose}
tokens={tokens}
loading={tokensLoading}
+205
View File
@@ -0,0 +1,205 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
CloseIcon,
LoaderIcon,
CheckIcon,
InfoIcon,
WarningIcon,
BottombarCollapseIcon,
BottombarExpandIcon,
ClearAllIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
const STATUS_META = {
pending: {
label: 'Queued',
tone: 'muted',
icon: <LoaderIcon className="icon icon--spin" size={16} />,
},
uploading: {
label: 'Uploading',
tone: 'accent',
icon: <LoaderIcon className="icon icon--spin" size={16} />,
},
success: {
label: 'Uploaded',
tone: 'success',
icon: <CheckIcon size={16} />,
},
duplicate: {
label: 'Duplicate',
tone: 'info',
icon: <InfoIcon size={16} />,
},
error: {
label: 'Failed',
tone: 'danger',
icon: <WarningIcon size={16} />,
},
};
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 (
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
<PanelHeader
className="upload-queue-overlay__header"
title={(
<span className="upload-queue-overlay__title">
<span>Uploads</span>
<span className="upload-queue-overlay__summary">{summary}</span>
</span>
)}
actions={(
<div className="upload-queue-overlay__controls">
<button
type="button"
className="icon-button ghost"
onClick={() => setCollapsed((value) => !value)}
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
>
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
</button>
<button
type="button"
className="icon-button ghost"
onClick={handleClearQueue}
disabled={hasActiveUploads || !queue.length}
aria-label="Clear completed uploads"
>
<ClearAllIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={() => setDismissed(true)}
aria-label="Hide upload queue"
>
<CloseIcon size={16} />
</button>
</div>
)}
/>
{!collapsed ? (
<ul className="upload-queue-overlay__list">
{[...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 (
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
{meta.icon}
</span>
<div className="upload-queue-overlay__details">
{item.status === 'success' && hasLink ? (
<button
type="button"
className="upload-queue-overlay__name-link"
onClick={handleNavigate}
>
{fileLabel}
</button>
) : (
<div className="upload-queue-overlay__name">
{fileLabel}
</div>
)}
<div className="upload-queue-overlay__meta-line">
{item.status === 'duplicate' && duplicateLabel ? (
<span className="upload-queue-overlay__meta-duplicate">
Duplicate of{' '}
<button
type="button"
className="upload-queue-overlay__meta-link"
onClick={handleNavigate}
>
{duplicateLabel}
</button>
</span>
) : item.status === 'error' && item.error ? (
<span className="upload-queue-overlay__meta-error" title={item.error}>
{item.error}
</span>
) : (
<>
<span>{meta.label}</span>
{documentId ? (
<span className="upload-queue-overlay__meta-id">
(
<button
type="button"
className="upload-queue-overlay__meta-link"
onClick={handleNavigate}
disabled={!hasLink}
>
{documentId}
</button>
)
</span>
) : null}
</>
)}
</div>
</div>
</li>
);
})}
</ul>
) : null}
</div>
);
};
export default UploadQueueOverlay;
@@ -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,
@@ -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;
+1 -1
View File
@@ -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,
-400
View File
@@ -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;
}
+6 -307
View File
@@ -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 (
<>
<DesktopWorkspaceView {...viewProps} />
<DesktopHelpOverlay open={helpOpen} onClose={onHelpClose} />
</>
);
return <DesktopWorkspaceView {...viewProps} />;
};
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(
<div className="desk-help-overlay" role="dialog" aria-modal="true" aria-labelledby="desk-help-title">
<div className="desk-help-overlay__backdrop" onClick={handleClose} />
<div className="desk-help-overlay__content">
<div className="desk-help-overlay__header">
<h2 id="desk-help-title">Desk view tips</h2>
<button
type="button"
className="icon-button"
onClick={handleClose}
aria-label="Close desk view tips"
ref={closeButtonRef}
>
<CloseIcon />
</button>
</div>
<div className="desk-help-overlay__body">
<p>Use the desk as a freeform workspace for triage and quick comparisons.</p>
<ul className="desk-help-overlay__list">
<li><strong>Single-click</strong> a document to open it in the detail panel.</li>
<li><strong>Double-click</strong> to open the zoomed preview.</li>
<li>
<strong>Drag</strong> selected cards to reposition them; build a selection with
{' '}
<kbd>Cmd</kbd>/<kbd>Ctrl</kbd>
{' '}+ click or Shift-click.
</li>
<li>
<strong>Cmd/Ctrl + click</strong> with an empty selection scoops up the stack under
{' '}the pointer.
</li>
<li><strong>Space</strong> clears the current selection.</li>
<li>
<strong>Drag tags</strong> from the sidebar onto a card to assign them, or fling a
{' '}tag away to remove it.
</li>
</ul>
</div>
<div className="desk-help-overlay__footer">
<button type="button" className="button primary" onClick={handleClose}>
Got it
</button>
</div>
</div>
</div>,
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
? (
<SelectionFloatingActions
selectionCount={selectionCount}
selectedDocumentIds={selectedDocumentIds}
selectedFolderIds={selectedFolderIds}
documentLookup={documentLookup}
tags={tags}
tagLookupById={tagLookupById}
correspondents={correspondents}
onBulkTagAdd={onBulkTagAdd}
onBulkTagRemove={onBulkTagRemove}
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/>
)
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
title,
subtitle,
sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions,
breadcrumbs: workspaceProps?.breadcrumbs || null,
selectionLabel: null,
floatingActions,
content: (
<DesktopWorkspace
{...workspaceProps}
/>
),
detail,
});
return {
...surfaceConfig,
supportsDetail: Boolean(detailProps),
};
};
@@ -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
? (
<SelectionFloatingActions
selectionCount={selectionCount}
selectedDocumentIds={selectedDocumentIds}
selectedFolderIds={selectedFolderIds}
documentLookup={documentLookup}
tags={tags}
tagLookupById={tagLookupById}
correspondents={correspondents}
onBulkTagAdd={onBulkTagAdd}
onBulkTagRemove={onBulkTagRemove}
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/>
)
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
title,
subtitle,
sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions,
breadcrumbs: workspaceProps?.breadcrumbs || null,
selectionLabel: null,
floatingActions,
content: (
<DesktopWorkspace
{...workspaceProps}
/>
),
detail,
});
return {
...surfaceConfig,
supportsDetail: Boolean(detailProps),
};
};
export default createDesktopSurface;
@@ -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;
+1 -5
View File
@@ -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})`;
@@ -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,
+1 -6
View File
@@ -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;
@@ -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);
+1 -11
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -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) {
+1 -11
View File
@@ -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;
@@ -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 (
<>
<section
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
>
{showHeader ? (
<div className="panel-section__header">
<div className="panel-section__titles">
<h2 className="documents-panel__title">
<BreadcrumbTrail
entries={trailEntries}
className="documents-panel__breadcrumbs"
separator="/"
/>
</h2>
{showingSearchResults && (
<div className="panel-section__subtitle">Search results</div>
)}
</div>
<div className="header-actions">
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isListView ? ' active' : ''}`}
onClick={() => handleSetViewMode('list')}
aria-pressed={isListView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => handleSetViewMode('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
onClick={() => handleSetViewMode('desk')}
aria-pressed={isDeskView}
title="Desk view"
>
<IconFileStack className="view-toggle__icon" size={18} />
</button>
</div>
<button className="secondary" onClick={onRefresh}>
Refresh
</button>
</div>
</div>
) : null}
{showDefaultEmptyState ? (
<div className="panel-section__body">
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
</div>
) : showGridSearchEmptyState ? (
<div className="panel-section__body">
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
</div>
) : showListSearchEmptyState ? (
<div className="panel-section__body">
<div className="empty-state">No documents match the current filters.</div>
</div>
) : (
<div className="panel-section__body">
<div
ref={assignScrollRef}
className="documents-scroll"
tabIndex={0}
onFocus={(event) => {
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 ? (
<DocumentsGrid
entries={entries}
selectedDocumentIdsSet={selectedSet}
selectedFolderIdsSet={selectedFolderSet}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onDocumentClick={handleDocumentClick}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
gridIconSize={gridIconSize}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
scrollRef={scrollRef}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
onClearSelection={onClearSelection}
onDocumentRename={onDocumentRename}
onFolderRename={onFolderRename}
/>
) : !showTableRows ? null : (
<DocumentsList
entries={entries}
focusedRowKey={focusedRowKey}
selectedDocumentIdsSet={selectedSet}
selectedFolderIdsSet={selectedFolderSet}
draggingDocumentIdsSet={draggingSet}
draggedFolderId={draggedFolderId}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
onFolderClick={handleFolderClick}
onFolderSelect={onFolderSelect}
onFolderDragOver={onFolderDragOver}
onFolderDragLeave={onFolderDragLeave}
onFolderDrop={onFolderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
onFolderRename={onFolderRename}
onDocumentClick={handleDocumentClick}
onDocumentActivate={handleDocumentActivate}
onDocumentDragStart={handleDocumentDragStartLocal}
onDocumentDragEnd={handleDocumentDragEndLocal}
onDocumentTagDragOver={handleDocumentTagDragOver}
onDocumentTagDragLeave={handleDocumentTagDragLeave}
onDocumentTagDrop={handleDocumentTagDrop}
onDocumentRename={onDocumentRename}
tagLookupById={tagLookupById}
onTagClick={onTagClick}
onCorrespondentClick={onCorrespondentClick}
activeCorrespondentIdSet={activeCorrespondentIdSet}
scrollRef={scrollRef}
onClearSelection={onClearSelection}
/>
)}
</div>
</div>
)}
</section>
<PreviewZoomOverlay
open={Boolean(previewDocId)}
display={previewDisplay}
onClose={closePreviewOverlay}
/>
</>
);
};
export default DocumentsPanel;
@@ -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'
? (
<button
type="button"
className="icon-button documents-toolbar__toggle"
onClick={onToggleIncludeDescendants}
aria-pressed={!includeDescendants}
aria-label={includeDescendants ? 'Include subfolders' : 'Limit to current folder'}
title={includeDescendants
? 'Including subfolders. Click to limit the search to the current folder.'
: 'Limiting to the current folder. Click to include subfolders again.'}
>
{includeDescendants ? <FoldersIcon /> : <FoldersOffIcon />}
</button>
)
: null;
const sortControls = typeof onSortFieldChange === 'function'
? (
<div className="documents-actions__sort-group">
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
{typeof onSortDirectionToggle === 'function' ? (
<button
type="button"
className="icon-button documents-toolbar__toggle documents-sort__direction"
onClick={onSortDirectionToggle}
aria-pressed={sortDirectionIsDesc}
aria-label={sortDirectionIsDesc ? 'Sort descending' : 'Sort ascending'}
title={sortDirectionTitle}
>
{sortDirectionIsDesc ? (
<SortDescendingLettersIcon size={18} />
) : (
<SortAscendingLettersIcon size={18} />
)}
</button>
) : null}
</div>
)
: null;
return (
<>
{includeDescendantsToggle ? (
<>
{includeDescendantsToggle}
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
</>
) : null}
{sortControls ? (
<>
{sortControls}
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
</>
) : null}
<div className="view-toggle" role="group" aria-label="Change view">
<button
type="button"
className={`view-toggle__button${isListView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('list')}
aria-pressed={isListView}
title="List view"
>
<ViewListIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isGridView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('grid')}
aria-pressed={isGridView}
title="Icons view"
>
<ViewGridIcon className="view-toggle__icon" size={18} />
</button>
<button
type="button"
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
onClick={() => onViewModeChange?.('desk')}
aria-pressed={isDeskView}
title="Desk view"
>
<IconFileStack className="view-toggle__icon" size={18} />
</button>
</div>
<span className="main-content__actions-divider" aria-hidden="true">
<MinusVerticalIcon />
</span>
<button
type="button"
className="icon-button"
onClick={onRefresh}
aria-label="Refresh"
title="Refresh"
>
<RefreshIcon />
</button>
</>
);
};
export default createDocumentsTableHeaderActions;
@@ -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 (
<QuickAddMenu
className="documents-sort__quickmenu"
options={options}
onSelectOption={handleSelect}
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
triggerContent={(
<span className="documents-sort__trigger-content">
<span className="documents-sort__label">{label}</span>
</span>
)}
triggerAriaLabel={`Sort by ${label}`}
triggerTitle={`Sort by ${label}`}
placeholder="Select sort field"
menuMinWidth={200}
align="start"
positionStrategy="absolute"
/>
);
};
export default SortFieldQuickMenu;
@@ -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
? (
<SelectionFloatingActions
selectionCount={selectionCount}
selectedDocumentIds={selectedDocumentIds}
selectedFolderIds={selectedFolderIds}
documentLookup={documentLookup}
tags={tags}
tagLookupById={tagLookupById}
correspondents={correspondents}
onBulkTagAdd={onBulkTagAdd}
onBulkTagRemove={onBulkTagRemove}
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/>
)
: null;
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
return createWorkspaceSurfaceConfig({
key: 'documents',
variant: 'documents',
title,
subtitle,
sidebarToggle,
parentBreadcrumb,
onNavigateParent,
actions,
breadcrumbs,
selectionLabel: null,
floatingActions,
content: (
<DocumentsPanel
{...tableProps}
showHeader={false}
onInspectDocument={onInspectDocument}
/>
),
detail,
});
};
export default createDocumentsSurface;
+10 -4
View File
@@ -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;
@@ -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,
};
};
@@ -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,
};
};
+1 -1
View File
@@ -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';
@@ -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 ? (
<small>Loading capability sets</small>
) : null}
{!capabilitySetsLoading && capabilityList.length ? (
<div className="settings-capability-list settings-capability-list--compact">
{capabilityList.map((value) => (
<span key={value} className="settings-capability-list__item badge tag-chip">
<span className="tag-chip__label">{formatCapabilityLabel(value)}</span>
</span>
))}
</div>
) : null}
</td>
<td className="settings-table__actions">
{isRevoked ? (
+104 -66
View File
@@ -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'
? (
<div className="menu__section">
<div className="menu__heading menu__heading--with-actions">
<span>Theme</span>
<div className="menu__heading-actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="menu__slider" htmlFor={neutralHueInputId}>
<span className="menu__slider-label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
</div>
)
: 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'
? (
<div className="sidebar-section sidebar-theme">
<div className="sidebar-section__header">
<h3>Theme</h3>
<div className="sidebar-section__actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="sidebar-slider" htmlFor={neutralHueInputId}>
<span className="sidebar-slider__label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
/>
<span className="sidebar-slider__value">{neutralHue}°</span>
</label>
</div>
)
: null;
return (
<aside className="sidebar">
<input
type="file"
ref={uploadInputRef}
style={{ display: 'none' }}
multiple
onChange={handleUploadInputChange}
/>
<PanelHeader
className="sidebar__header"
leading={(
@@ -474,7 +505,7 @@ const Sidebar = ({
&& typeof document !== 'undefined'
? createPortal(
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
className={menuClassName}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
@@ -509,7 +540,7 @@ const Sidebar = ({
<div className="menu__footer">
<button
type="button"
className="menu__settings"
className="menu__button"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
@@ -517,37 +548,45 @@ const Sidebar = ({
</button>
<button
type="button"
className="menu__logout"
className="menu__button menu__button--danger"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
{themeMenuSection}
</div>,
document.body,
)
: null}
</>
)}
actions={
handleCollapse
? [
(
<button
key="collapse"
type="button"
className="icon-button"
onClick={handleCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<SidebarCollapseIcon />
</button>
),
]
: null
}
actions={(
<>
{handleCollapse ? (
<button
type="button"
className="icon-button sidebar__collapse-button"
onClick={handleCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<SidebarCollapseIcon />
</button>
) : null}
<button
type="button"
className="icon-button"
onClick={handleUploadButtonClick}
disabled={!onUploadFiles}
title="Upload documents"
aria-label="Upload documents"
>
<UploadIcon size={18} />
</button>
</>
)}
/>
<div className="panel-body sidebar__body">
{status && (
@@ -722,7 +761,6 @@ const Sidebar = ({
</ul>
</div>
</div>
{themeControls ? <div className="sidebar__footer">{themeControls}</div> : null}
</aside>
);
};
+15
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+149
View File
@@ -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;
}
+99
View File
@@ -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;
}
+56
View File
@@ -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;
}
+14
View File
@@ -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;
}
+295
View File
@@ -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%;
}
@@ -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);
}
+108
View File
@@ -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;
}
+736
View File
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
+335
View File
@@ -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;
}
+46
View File
@@ -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;
}
+22
View File
@@ -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';
@@ -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);
}
+17
View File
@@ -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;
}
}
+147
View File
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
+337
View File
@@ -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;
}
+655
View File
@@ -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);
}
+176
View File
@@ -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);
}
@@ -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%;
}
@@ -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;
}
@@ -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;
}
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
import composeClassName from './classNames';
const PanelHeader = ({
className = '',
+3
View File
@@ -0,0 +1,3 @@
export const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
export default composeClassName;
+51 -2
View File
@@ -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 }) => (
<TablerChevronRight
@@ -132,6 +136,15 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/>
);
export const UploadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconUpload
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowLeft
className={composeClassName('icon', className)}
@@ -195,6 +208,33 @@ export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6,
/>
);
export const BottombarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutBottombarCollapse
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const BottombarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutBottombarExpand
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ClearAllIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconClearAll
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconFolderPlus
className={composeClassName('icon', className)}
@@ -429,6 +469,15 @@ export const LoaderIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) =
/>
);
export const WarningIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => (
<IconAlertTriangle
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconWindowMaximize
className={composeClassName('icon', className)}
+14 -2
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { clamp } from '../utils/math';
const DEFAULT_VIEWPORT_MARGIN = 8;
const clamp = (value, min, max) => 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);
+57
View File
@@ -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,
};
+13
View File
@@ -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,
};