Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,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,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,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,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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||
import composeClassName from './classNames';
|
||||
|
||||
const PanelHeader = ({
|
||||
className = '',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||
|
||||
export default composeClassName;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user