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

This commit is contained in:
2025-11-12 02:34:17 +01:00
23 changed files with 1611 additions and 292 deletions
+4 -3
View File
@@ -3,10 +3,11 @@ import Sidebar from '../sidebar/Sidebar';
import { useSidebarContext } from '../sidebar/SidebarContext';
const DocumentsLayout = ({ sidebarProps, children }) => {
const { collapsed } = useSidebarContext();
const { collapsed, sidebarSuppressed } = useSidebarContext();
const sidebarHidden = collapsed || sidebarSuppressed;
return (
<main className={`documents-main${collapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
{!collapsed ? <Sidebar {...sidebarProps} /> : null}
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
{!sidebarHidden ? <Sidebar {...sidebarProps} /> : null}
{children}
</main>
);
+102 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
@@ -28,9 +28,105 @@ const DocumentsRouteContent = () => {
notifyApiError,
} = useAppShell();
const navigate = useNavigate();
const { collapsed: sidebarCollapsed, setCollapsed } = useSidebarContext();
const { collapsed: sidebarCollapsed, sidebarSuppressed, setCollapsed, setSidebarSuppressed } = useSidebarContext();
const expandSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
const getDetailPanelWidth = useCallback(() => {
if (typeof window === 'undefined') {
return null;
}
const panelEl = document.querySelector('.detail-panel');
if (panelEl) {
const rect = panelEl.getBoundingClientRect();
if (Number.isFinite(rect?.width)) {
return rect.width;
}
}
const rootStyles = window.getComputedStyle(document.documentElement);
const varValue = rootStyles.getPropertyValue('--detail-panel-width');
const parsed = parseFloat(varValue);
return Number.isFinite(parsed) ? parsed : null;
}, []);
const getSidebarWidth = useCallback(() => {
if (typeof window === 'undefined') {
return null;
}
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) {
const rect = sidebarEl.getBoundingClientRect();
if (Number.isFinite(rect?.width)) {
return rect.width;
}
}
const rootStyles = window.getComputedStyle(document.documentElement);
const varValue = rootStyles.getPropertyValue('--sidebar-width');
const parsed = parseFloat(varValue);
return Number.isFinite(parsed) ? parsed : null;
}, []);
const shouldCloseDetailPanelForSidebar = useCallback(() => {
if (typeof window === 'undefined') {
return false;
}
if (!detailPanelOpen && !previewDocumentId) {
return false;
}
const detailWidth = getDetailPanelWidth();
const sidebarWidth = getSidebarWidth();
if (!Number.isFinite(detailWidth) || !Number.isFinite(sidebarWidth)) {
return false;
}
return detailWidth + sidebarWidth > window.innerWidth * (2 / 3);
}, [detailPanelOpen, previewDocumentId, getDetailPanelWidth, getSidebarWidth]);
const closeAnyDetailPanel = useCallback(() => {
if (previewDocumentId && typeof closeDocumentPreview === 'function') {
closeDocumentPreview();
return true;
}
if (detailPanelOpen && typeof detailPanelProps?.onClose === 'function') {
detailPanelProps.onClose();
return true;
}
return false;
}, [previewDocumentId, closeDocumentPreview, detailPanelOpen, detailPanelProps]);
const sidebarWidthRef = useRef(null);
useEffect(() => {
sidebarWidthRef.current = getSidebarWidth();
}, [getSidebarWidth]);
const expandSidebar = useCallback(() => {
if (shouldCloseDetailPanelForSidebar()) {
closeAnyDetailPanel();
}
setSidebarSuppressed(false);
setCollapsed(false);
}, [setCollapsed, setSidebarSuppressed, shouldCloseDetailPanelForSidebar, closeAnyDetailPanel]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleSidebarResize = (event) => {
const nextWidth = Number.isFinite(event?.detail?.width)
? event.detail.width
: getSidebarWidth();
const prevWidth = sidebarWidthRef.current;
if (Number.isFinite(nextWidth)) {
sidebarWidthRef.current = nextWidth;
}
if (Number.isFinite(nextWidth) && Number.isFinite(prevWidth) && nextWidth <= prevWidth) {
return;
}
if (shouldCloseDetailPanelForSidebar()) {
closeAnyDetailPanel();
}
};
window.addEventListener('sidebar-width-change', handleSidebarResize);
return () => window.removeEventListener('sidebar-width-change', handleSidebarResize);
}, [shouldCloseDetailPanelForSidebar, closeAnyDetailPanel, getSidebarWidth]);
const sidebarPropsWithActions = useMemo(
() => ({
@@ -67,8 +163,10 @@ const DocumentsRouteContent = () => {
navigate(target);
}, [navigate]);
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
const { surface } = useWorkspaceSurface({
sidebarCollapsed,
sidebarHidden,
onExpandSidebar: expandSidebar,
documentsTableProps,
detailPanelProps,
+1 -1
View File
@@ -184,7 +184,7 @@ const AppStateProvider = ({ children }) => {
}
try {
const { data } = await api.get('/auth/tenants');
const { data } = await api.get('/tenants');
if (!abort) {
dispatch({
type: 'SET_TENANTS',
+67 -8
View File
@@ -4,7 +4,6 @@ const useDocumentPreview = ({
routeDocumentId,
documents,
searchResults,
setDocuments,
selectedFolder,
assetManager,
api,
@@ -17,6 +16,7 @@ const useDocumentPreview = ({
setActivePreviewId,
}) => {
const [previewEntries, setPreviewEntries] = useState(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
const previewInflightRef = useRef(new Map());
const previewReturnPathRef = useRef(null);
@@ -46,6 +46,35 @@ const useDocumentPreview = ({
});
}, []);
const cachePreviewDocument = useCallback((doc) => {
if (!doc?.id) {
return;
}
setPreviewDocuments((prev) => {
const existing = prev.get(doc.id);
if (existing === doc) {
return prev;
}
const next = new Map(prev);
next.set(doc.id, doc);
return next;
});
}, []);
const removeCachedPreviewDocument = useCallback((documentId) => {
if (!documentId) {
return;
}
setPreviewDocuments((prev) => {
if (!prev.has(documentId)) {
return prev;
}
const next = new Map(prev);
next.delete(documentId);
return next;
});
}, []);
const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => {
if (!documentId) return null;
@@ -116,12 +145,16 @@ const useDocumentPreview = ({
throw new Error('Document metadata unavailable.');
}
setDocuments((prev) => {
if (prev.some((item) => item.id === doc.id)) {
return prev;
}
return [doc, ...prev];
});
const existsInDocuments = documents.some((item) => item.id === doc.id);
const existsInSearch = Array.isArray(searchResults)
? searchResults.some((item) => item.id === doc.id)
: false;
if (existsInDocuments || existsInSearch) {
removeCachedPreviewDocument(doc.id);
} else {
cachePreviewDocument(doc);
}
}
if (!previewReturnPathRef.current) {
@@ -138,10 +171,11 @@ const useDocumentPreview = ({
searchResults,
documents,
assetManager,
setDocuments,
ensurePreviewUrl,
setActivePreviewId,
api,
cachePreviewDocument,
removeCachedPreviewDocument,
],
);
@@ -207,8 +241,33 @@ const useDocumentPreview = ({
};
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
useEffect(() => {
setPreviewDocuments((prev) => {
if (!prev.size) {
return prev;
}
const next = new Map(prev);
let changed = false;
const prune = (list) => {
if (!Array.isArray(list)) {
return;
}
list.forEach((doc) => {
if (doc?.id && next.has(doc.id)) {
next.delete(doc.id);
changed = true;
}
});
};
prune(documents);
prune(searchResults);
return changed ? next : prev;
});
}, [documents, searchResults]);
return {
previewEntries,
previewDocuments,
ensurePreviewUrl,
ensurePreviewData,
openDocumentPreview,
+3 -3
View File
@@ -5,7 +5,7 @@ import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
import createDesktopSurface from '../desktop/createDesktopSurface';
export const useWorkspaceSurface = ({
sidebarCollapsed,
sidebarHidden,
onExpandSidebar,
documentsTableProps,
detailPanelProps,
@@ -25,7 +25,7 @@ export const useWorkspaceSurface = ({
onNavigateParent,
}) => {
const renderSidebarToggle = useCallback(() => {
if (!sidebarCollapsed) {
if (!sidebarHidden) {
return null;
}
return (
@@ -39,7 +39,7 @@ export const useWorkspaceSurface = ({
<SidebarExpandIcon />
</button>
);
}, [sidebarCollapsed, onExpandSidebar]);
}, [sidebarHidden, onExpandSidebar]);
const documentsSurface = useMemo(() => {
if (!documentsTableProps) {
+5 -2
View File
@@ -10,6 +10,7 @@ import {
const useDetailWorkspace = ({
documents,
searchResults,
previewDocuments,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -195,8 +196,10 @@ const useDetailWorkspace = ({
return null;
}
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, searchResults, documents]);
return pool.find((doc) => doc.id === previewDocumentId)
|| previewDocuments?.get?.(previewDocumentId)
|| null;
}, [previewDocumentId, searchResults, documents, previewDocuments]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
+147 -62
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
summaryPlacement = 'inline',
summaryTabLabel = 'Summary',
summaryTabId = 'summary',
leadingTabs = [],
trailingTabs = [],
tabsPlacement = 'top',
summaryLayout = 'default',
}) => {
const base = classNamePrefix;
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
detailItems={metadataItems}
layout={summaryLayout}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, metadataItems]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
), [base, metadataItems]);
const summaryInline = summaryPlacement !== 'tabs';
const summaryTab = useMemo(() => {
if (summaryPlacement !== 'tabs') {
return null;
}
return {
id: summaryTabId,
label: summaryTabLabel,
render: () => (
<div className={`${base}__summary-tab-content`}>
{renderSummarySection()}
</div>
),
};
}, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]);
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
: []),
[trailingTabs],
);
const summaryNode = summaryInline
? (
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
: null;
const visibleTabs = useMemo(() => {
const tabsList = [];
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
),
});
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
}
if (summaryTab) {
tabsList.push(summaryTab);
}
if (summaryPlacement !== 'tabs') {
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => renderDetailsSection(),
});
}
if (showContentTab && contentConfig) {
tabsList.push({
@@ -183,30 +251,38 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
if (normalizedTrailingTabs.length) {
tabsList.push(...normalizedTrailingTabs);
}
return tabsList;
}, [
base,
detailsTabLabel,
metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
summaryTab,
normalizedLeadingTabs,
normalizedTrailingTabs,
summaryPlacement,
renderDetailsSection,
]);
const fallbackTabId = useMemo(() => {
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, resetKey, isControlled]);
}, [fallbackTabId, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
const tabNav = (
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
const tabPanels = (
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
);
const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
return (
<>
<DocumentSummarySection
document={document}
{...summaryProps}
/>
{summaryNode}
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
</div>
</div>
) : (
<div className={`${base}__tabs-wrapper`}>
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
<div className={tabsWrapperClass}>
{tabsPlacement !== 'bottom' ? tabNav : null}
{tabsPlacement === 'bottom' ? tabPanels : null}
{tabsPlacement === 'bottom' ? tabNav : null}
{tabsPlacement !== 'bottom' ? tabPanels : null}
</div>
)}
</>
+213 -89
View File
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued
onUpdateIssued,
layout = 'default',
detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
return null;
}
const TitleSection = () => (
editableTitle && isTitleEditing ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)
);
const issuedDisplay = editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const metaItems = [
{
key: 'issued',
label: 'Issued',
valueContent: issuedDisplay,
error: issuedError,
},
...metaRows.map((row) => ({
key: row.key,
label: row.label,
fallbackValue: row.value,
})),
];
const detailRows = Array.isArray(detailItems)
? detailItems.map((item, index) => ({
key: `detail-${item?.label || index}`,
label: item?.label || '—',
fallbackValue: item?.value,
}))
: [];
const compactRows = [...metaItems, ...detailRows];
const renderTags = () => (
<section className="document-summary__section document-summary__section--tags">
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
</section>
);
const renderCorrespondents = () => (
<section className="document-summary__section document-summary__section--correspondents">
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
</section>
);
if (isCompactLayout) {
return (
<div className="document-summary document-summary--compact">
<section className="document-summary__section document-summary__title-row">
<TitleSection />
</section>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
{renderTags()}
{renderCorrespondents()}
{compactRows.length ? (
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
<dl className="document-summary__details-list document-summary__details-list--meta">
{compactRows.map((item) => (
<div key={item.key} className="document-summary__details-row">
<dt>{item.label}</dt>
<dd>
{item.valueContent != null && item.valueContent !== ''
? item.valueContent
: item.fallbackValue || '—'}
</dd>
{item.error ? <div className="status-inline error">{item.error}</div> : null}
</div>
))}
</dl>
</section>
) : null}
</div>
);
}
return (
<div className="document-summary">
<div className="doc-title-row">
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta">
<div className="detail-meta__row">
<span className="detail-meta__label">Issued:</span>
{editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
{issuedDisplay}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
))}
</div>
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
/>
{renderTags()}
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
/>
{renderCorrespondents()}
</div>
);
};
@@ -59,6 +59,34 @@ const DocumentsPanel = ({
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const currentFolderId = useMemo(() => {
if (showingSearchResults) {
return null;
}
const trail = Array.isArray(breadcrumbs) ? breadcrumbs : [];
if (trail.length === 0) {
return 'root';
}
return trail[trail.length - 1]?.id || 'root';
}, [breadcrumbs, showingSearchResults]);
const selectionContextRef = useRef(null);
useEffect(() => {
const nextContext = showingSearchResults
? { type: 'search', marker: searchResults }
: { type: 'folder', marker: currentFolderId || 'root' };
const previous = selectionContextRef.current;
selectionContextRef.current = nextContext;
if (!previous) {
return;
}
const changed = previous.type !== nextContext.type
|| previous.marker !== nextContext.marker;
if (changed) {
onClearSelection?.();
}
}, [showingSearchResults, currentFolderId, searchResults, onClearSelection]);
const entries = useMemo(() => {
const list = [];
if (!showingSearchResults) {
@@ -328,6 +328,7 @@ const useDocumentsWorkspace = ({
const {
previewEntries,
previewDocuments,
ensurePreviewData,
openDocumentPreview,
closeDocumentPreview,
@@ -337,7 +338,6 @@ const useDocumentsWorkspace = ({
routeDocumentId: previewDocumentId,
documents,
searchResults,
setDocuments,
selectedFolder,
assetManager,
api,
@@ -410,8 +410,13 @@ const useDocumentsWorkspace = ({
if (Array.isArray(searchResults)) {
push(searchResults);
}
previewDocuments.forEach((doc, id) => {
if (doc && id && !map.has(id)) {
map.set(id, doc);
}
});
return map;
}, [documents, searchResults]);
}, [documents, searchResults, previewDocuments]);
const {
tags,
@@ -1158,6 +1163,7 @@ const useDocumentsWorkspace = ({
} = useDetailWorkspace({
documents,
searchResults,
previewDocuments,
focusedDocumentId,
selectionOrder,
selectedDocumentIds,
@@ -30,10 +30,10 @@ const useTenantManager = ({
}
if (refreshOnly) {
const { data } = await apiClient.get('/auth/tenants');
const { data } = await apiClient.get('/tenants');
appDispatch({
type: 'SET_TENANTS',
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
tenants: Array.isArray(data) ? data : [],
});
return;
}
+61 -23
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
@@ -13,7 +13,10 @@ const DocumentViewerLayout = ({
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
layoutMode = 'split',
}) => {
const isStacked = layoutMode === 'stacked';
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
@@ -74,30 +77,65 @@ const DocumentViewerLayout = ({
);
}, [previewEntry, document]);
const renderViewportPane = useCallback(() => (
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
), [previewEntry?.url, previewLoadingMessage, previewContent]);
const viewportPane = renderViewportPane();
const stackedLeadingTabs = useMemo(() => (
isStacked
? [
{
id: 'preview',
label: 'Preview',
render: () => renderViewportPane(),
},
]
: []
), [isStacked, renderViewportPane]);
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
const summaryPlacement = 'tabs';
const tabsPlacement = isStacked ? 'bottom' : 'top';
const summaryLayout = 'compact';
const detailsPane = (
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={resolvedDefaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
summaryPlacement={summaryPlacement}
summaryLayout={summaryLayout}
leadingTabs={stackedLeadingTabs}
tabsPlacement={tabsPlacement}
{...infoPanelProps}
/>
</div>
</div>
);
if (isStacked) {
return detailsPane;
}
return (
<>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={defaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
{...infoPanelProps}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
{detailsPane}
{viewportPane}
</>
);
};
+255 -1
View File
@@ -26,6 +26,77 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
import { useSidebarContext } from '../sidebar/SidebarContext';
const DETAIL_PANEL_WIDTH_STORAGE_KEY = 'detailPanelWidth';
const MIN_DETAIL_PANEL_WIDTH = 320;
const MAX_DETAIL_PANEL_WIDTH = 960;
const isBrowser = typeof window !== 'undefined';
const isDocumentAvailable = typeof document !== 'undefined';
const getDetailPanelBounds = () => {
if (!isBrowser) {
return {
min: MIN_DETAIL_PANEL_WIDTH,
max: MAX_DETAIL_PANEL_WIDTH,
};
}
const viewportWidth = Math.max(window.innerWidth, 1);
const minFractionWidth = viewportWidth / 5;
const maxFractionWidth = viewportWidth * 0.75;
const rawMin = Math.max(MIN_DETAIL_PANEL_WIDTH, minFractionWidth);
const rawMax = Math.min(MAX_DETAIL_PANEL_WIDTH, maxFractionWidth);
if (rawMin >= rawMax) {
const fallback = Math.min(Math.max(rawMin, viewportWidth * 0.5), MAX_DETAIL_PANEL_WIDTH);
return { min: fallback, max: fallback };
}
return {
min: rawMin,
max: rawMax,
};
};
const clampDetailPanelWidth = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const { min, max } = getDetailPanelBounds();
return Math.min(Math.max(value, min), max);
};
const loadStoredDetailPanelWidth = () => {
if (!isBrowser) {
return null;
}
try {
const raw = window.localStorage?.getItem(DETAIL_PANEL_WIDTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch (error) {
console.warn('Failed to read detail panel width', error);
return null;
}
};
const applyDetailPanelWidth = (width) => {
if (!isDocumentAvailable || width == null) {
return;
}
document.documentElement.style.setProperty('--detail-panel-width', `${width}px`);
};
const getSidebarWidthFromRoot = () => {
if (!isBrowser || !isDocumentAvailable) {
return null;
}
const computed = window.getComputedStyle(document.documentElement);
const parsed = parseFloat(computed.getPropertyValue('--sidebar-width'));
return Number.isFinite(parsed) ? parsed : null;
};
export const createDocumentViewerHeaderActions = ({
document,
@@ -99,6 +170,7 @@ const DocumentViewerPanel = ({
}) => {
const navigate = useNavigate();
const isSidebarVariant = variant === 'sidebar';
const { setSidebarSuppressed } = useSidebarContext();
const sortedCorrespondents = useMemo(
() => sortCorrespondents(document?.correspondents || []),
[document],
@@ -239,6 +311,174 @@ const DocumentViewerPanel = ({
const panelRef = useRef(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const pendingWidthRef = useRef(null);
const [isResizingPanel, setIsResizingPanel] = useState(false);
const [detailPanelWidth, setDetailPanelWidth] = useState(() => {
if (!isBrowser) {
return null;
}
const stored = loadStoredDetailPanelWidth();
return stored != null ? clampDetailPanelWidth(stored) : null;
});
useEffect(() => {
if (detailPanelWidth != null) {
const clamped = clampDetailPanelWidth(detailPanelWidth);
if (clamped != null) {
applyDetailPanelWidth(clamped);
}
}
}, [detailPanelWidth]);
useEffect(() => {
if (!isSidebarVariant) {
setSidebarSuppressed(false);
return undefined;
}
const updateSuppression = () => {
if (!isBrowser) {
setSidebarSuppressed(false);
return;
}
const panelWidth = pendingWidthRef.current
?? detailPanelWidth
?? panelRef.current?.getBoundingClientRect().width;
const sidebarWidth = getSidebarWidthFromRoot();
if (!Number.isFinite(panelWidth)) {
setSidebarSuppressed(false);
return;
}
const minMainContentWidth = (window.innerWidth * 2) / 5;
const occupiedWidth = panelWidth + (Number.isFinite(sidebarWidth) ? sidebarWidth : 0);
const availableWidth = window.innerWidth - occupiedWidth;
setSidebarSuppressed(availableWidth < minMainContentWidth);
};
updateSuppression();
const handleWindowResize = () => updateSuppression();
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, [isSidebarVariant, detailPanelWidth, setSidebarSuppressed]);
useEffect(() => {
if (!isSidebarVariant) {
setSidebarSuppressed(false);
}
}, [isSidebarVariant, setSidebarSuppressed]);
useEffect(() => {
if (!isBrowser) {
return undefined;
}
const handleResize = () => {
setDetailPanelWidth((prev) => {
if (prev == null) {
return prev;
}
const clamped = clampDetailPanelWidth(prev);
if (clamped != null && clamped !== prev) {
applyDetailPanelWidth(clamped);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(clamped)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
return clamped;
}
return prev;
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleResizePointerDown = useCallback((event) => {
if (!isSidebarVariant || !panelRef.current || !isBrowser) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const target = event.currentTarget;
target.setPointerCapture?.(pointerId);
setIsResizingPanel(true);
const rect = panelRef.current.getBoundingClientRect();
const startWidth = rect.width;
const startX = event.clientX;
const updateWidth = (nextWidth) => {
const clamped = clampDetailPanelWidth(nextWidth);
if (clamped != null) {
pendingWidthRef.current = clamped;
applyDetailPanelWidth(clamped);
if (isSidebarVariant && isBrowser) {
setSidebarSuppressed(clamped > window.innerWidth / 2);
}
}
};
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = startX - moveEvent.clientX;
updateWidth(startWidth + delta);
};
const handlePointerUp = (upEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
target.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setIsResizingPanel(false);
if (pendingWidthRef.current != null) {
const finalizedWidth = pendingWidthRef.current;
pendingWidthRef.current = null;
setDetailPanelWidth(finalizedWidth);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}, [isSidebarVariant, setSidebarSuppressed]);
const handleResizeKeyDown = useCallback((event) => {
if (!isSidebarVariant || !isBrowser) {
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
const baseWidth = pendingWidthRef.current
?? detailPanelWidth
?? panelRef.current?.getBoundingClientRect().width;
if (!Number.isFinite(baseWidth)) {
return;
}
event.preventDefault();
const step = event.shiftKey ? 40 : 20;
const delta = event.key === 'ArrowLeft' ? step : -step;
const nextWidth = clampDetailPanelWidth(baseWidth + delta);
if (nextWidth == null) {
return;
}
setDetailPanelWidth(nextWidth);
try {
window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(nextWidth)));
} catch (error) {
console.warn('Failed to persist detail panel width', error);
}
}, [detailPanelWidth, isSidebarVariant]);
const viewerClassName = isStackedLayout
? 'document-viewer document-viewer--stacked'
@@ -403,6 +643,18 @@ const DocumentViewerPanel = ({
)
: null;
const resizeHandle = isSidebarVariant ? (
<button
type="button"
className={`detail-panel__resize-handle${isResizingPanel ? ' is-active' : ''}`}
aria-label="Resize detail panel"
onPointerDown={handleResizePointerDown}
onKeyDown={handleResizeKeyDown}
>
<span aria-hidden="true" />
</button>
) : null;
const loadingSection = (
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--loading">
@@ -432,6 +684,7 @@ const DocumentViewerPanel = ({
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
layoutMode={isStackedLayout ? 'stacked' : 'split'}
/>
</section>
</div>
@@ -459,7 +712,8 @@ const DocumentViewerPanel = ({
if (isSidebarVariant) {
return (
<>
<aside className="detail-panel panel" ref={panelRef}>
<aside className={`detail-panel panel${isResizingPanel ? ' detail-panel--resizing' : ''}`} ref={panelRef}>
{resizeHandle}
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}
+4 -9
View File
@@ -1,6 +1,5 @@
import { useLayoutEffect, useState } from 'react';
const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio)
export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4;
const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style';
@@ -25,14 +24,10 @@ const ensurePortraitRatioStyle = () => {
};
const computeStackedLayoutBreakpoint = () => {
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900;
const portraitViewportWidth = viewportHeight
* DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO
* PORTRAIT_WIDTH_TO_HEIGHT;
const detailsColumnWidth = 320; // px ~ 20rem for metadata & tabs
const gutterAllowance = 48; // padding + grid gap
return portraitViewportWidth + detailsColumnWidth + gutterAllowance;
if (typeof window === 'undefined') {
return 900;
}
return window.innerWidth / 2;
};
export const useViewerLayoutMode = (ref, dependency) => {
+304 -7
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronIcon,
@@ -24,6 +24,80 @@ import useFloatingMenu from '../ui/useFloatingMenu';
import { getTagColorStyle } from '../utils/colors';
import { useSidebarContext } from './SidebarContext';
const SIDEBAR_WIDTH_STORAGE_KEY = 'documentsSidebarWidth';
const SIDEBAR_WIDTH_EVENT = 'sidebar-width-change';
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 640;
const isBrowser = typeof window !== 'undefined';
const isDocumentAvailable = typeof document !== 'undefined';
const notifySidebarWidthChange = (width) => {
if (!isBrowser) {
return;
}
try {
window.dispatchEvent(
new CustomEvent(SIDEBAR_WIDTH_EVENT, {
detail: { width },
}),
);
} catch (error) {
console.warn('Failed to dispatch sidebar width change', error);
}
};
const getSidebarBounds = () => {
if (!isBrowser) {
return {
min: MIN_SIDEBAR_WIDTH,
max: MAX_SIDEBAR_WIDTH,
};
}
const viewportWidth = Math.max(window.innerWidth, 1);
const minFractionWidth = viewportWidth / 6;
const maxFractionWidth = viewportWidth / 3;
const rawMin = Math.max(MIN_SIDEBAR_WIDTH, minFractionWidth);
const rawMax = Math.min(MAX_SIDEBAR_WIDTH, maxFractionWidth);
if (rawMin >= rawMax) {
const fallback = Math.min(Math.max(rawMin, viewportWidth / 4), MAX_SIDEBAR_WIDTH);
return { min: fallback, max: fallback };
}
return { min: rawMin, max: rawMax };
};
const clampSidebarWidth = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const { min, max } = getSidebarBounds();
return Math.min(Math.max(value, min), max);
};
const loadStoredSidebarWidth = () => {
if (!isBrowser) {
return null;
}
try {
const raw = window.localStorage?.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch (error) {
console.warn('Failed to read sidebar width', error);
return null;
}
};
const applySidebarWidth = (width) => {
if (!isDocumentAvailable || width == null) {
return;
}
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
notifySidebarWidthChange(width);
};
const FolderNode = ({
node,
depth,
@@ -195,15 +269,152 @@ const Sidebar = ({
neutralHue,
setNeutralHue,
resetNeutralHue,
neutralChroma,
setNeutralChroma,
resetNeutralChroma,
neutralContrast,
setNeutralContrast,
resetNeutralContrast,
themeMode,
cycleThemeMode,
themeModes,
sidebarSuppressed,
} = useSidebarContext();
const uploadInputRef = useRef(null);
const sidebarRef = useRef(null);
const pendingWidthRef = useRef(null);
const [isResizingSidebar, setIsResizingSidebar] = useState(false);
const [sidebarWidthState, setSidebarWidthState] = useState(() => {
if (!isBrowser) {
return null;
}
const stored = loadStoredSidebarWidth();
return stored != null ? clampSidebarWidth(stored) : null;
});
useEffect(() => {
if (sidebarWidthState != null) {
const clamped = clampSidebarWidth(sidebarWidthState);
if (clamped != null) {
applySidebarWidth(clamped);
}
}
}, [sidebarWidthState]);
useEffect(() => {
if (!isBrowser) {
return undefined;
}
const handleResize = () => {
setSidebarWidthState((prev) => {
if (prev == null) {
return prev;
}
const clamped = clampSidebarWidth(prev);
if (clamped != null && clamped !== prev) {
applySidebarWidth(clamped);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clamped)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
return clamped;
}
return prev;
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleSidebarResizePointerDown = useCallback((event) => {
if (!isBrowser || !sidebarRef.current) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const target = event.currentTarget;
target.setPointerCapture?.(pointerId);
setIsResizingSidebar(true);
const rect = sidebarRef.current.getBoundingClientRect();
const startWidth = rect.width;
const startX = event.clientX;
const updateWidth = (nextWidth) => {
const clamped = clampSidebarWidth(nextWidth);
if (clamped != null) {
pendingWidthRef.current = clamped;
applySidebarWidth(clamped);
}
};
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = moveEvent.clientX - startX;
updateWidth(startWidth + delta);
};
const handlePointerUp = (upEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
target.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setIsResizingSidebar(false);
if (pendingWidthRef.current != null) {
const finalizedWidth = pendingWidthRef.current;
pendingWidthRef.current = null;
setSidebarWidthState(finalizedWidth);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}, []);
const handleSidebarResizeKeyDown = useCallback((event) => {
if (!isBrowser) {
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
const baseWidth = pendingWidthRef.current
?? sidebarWidthState
?? sidebarRef.current?.getBoundingClientRect().width;
if (!Number.isFinite(baseWidth)) {
return;
}
event.preventDefault();
const step = event.shiftKey ? 40 : 20;
const delta = event.key === 'ArrowRight' ? step : -step;
const nextWidth = clampSidebarWidth(baseWidth + delta);
if (nextWidth == null) {
return;
}
setSidebarWidthState(nextWidth);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(nextWidth)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
}, [sidebarWidthState]);
const handleCollapse = useCallback(() => {
setCollapsed(true);
}, [setCollapsed]);
const neutralHueInputId = useId();
const neutralChromaInputId = useId();
const neutralContrastInputId = useId();
const sortedCorrespondents = useMemo(() => {
if (!Array.isArray(correspondents)) {
return [];
@@ -281,9 +492,11 @@ const Sidebar = ({
[onUploadFiles, selectedFolder],
);
const handleNeutralHueReset = useCallback(() => {
const handleThemeAdjustmentsReset = useCallback(() => {
resetNeutralHue();
}, [resetNeutralHue]);
resetNeutralChroma();
resetNeutralContrast();
}, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]);
const handleNeutralHueChange = useCallback(
(value) => {
@@ -300,6 +513,44 @@ const Sidebar = ({
[resetNeutralHue, setNeutralHue],
);
const handleNeutralChromaChange = useCallback(
(value) => {
if (value === '') {
resetNeutralChroma();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralChroma(parsed);
},
[resetNeutralChroma, setNeutralChroma],
);
const handleNeutralContrastChange = useCallback(
(value) => {
if (value === '') {
resetNeutralContrast();
return;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return;
}
setNeutralContrast(parsed);
},
[resetNeutralContrast, setNeutralContrast],
);
const formatSliderValue = useCallback((value) => {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return value;
}
return parsed.toFixed(2);
}, []);
const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES;
const themeModeIndex = themeModeList.indexOf(themeMode);
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
@@ -336,9 +587,9 @@ const Sidebar = ({
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
onClick={handleThemeAdjustmentsReset}
aria-label="Reset theme adjustments"
title="Reset theme adjustments"
>
<RestoreIcon size={16} />
</button>
@@ -354,9 +605,38 @@ const Sidebar = ({
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
onDoubleClick={resetNeutralHue}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
<label className="menu__slider" htmlFor={neutralChromaInputId}>
<span className="menu__slider-label">Chroma</span>
<input
id={neutralChromaInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralChroma}
onChange={(event) => handleNeutralChromaChange(event.target.value)}
onDoubleClick={resetNeutralChroma}
/>
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
</label>
<label className="menu__slider" htmlFor={neutralContrastInputId}>
<span className="menu__slider-label">Contrast</span>
<input
id={neutralContrastInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralContrast}
onChange={(event) => handleNeutralContrastChange(event.target.value)}
onDoubleClick={resetNeutralContrast}
/>
<span className="menu__slider-value">{formatSliderValue(neutralContrast)}</span>
</label>
</div>
)
: null;
@@ -470,8 +750,25 @@ const Sidebar = ({
);
const rootNode = folderNodes.get('root');
const sidebarClassNames = ['sidebar'];
if (isResizingSidebar) {
sidebarClassNames.push('sidebar--resizing');
}
if (sidebarSuppressed) {
sidebarClassNames.push('sidebar--suppressed');
}
return (
<aside className="sidebar">
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef}>
<button
type="button"
className={`sidebar__resize-handle${isResizingSidebar ? ' is-active' : ''}`}
aria-label="Resize sidebar"
onPointerDown={handleSidebarResizePointerDown}
onKeyDown={handleSidebarResizeKeyDown}
>
<span aria-hidden="true" />
</button>
<input
type="file"
ref={uploadInputRef}
+143 -10
View File
@@ -12,19 +12,29 @@ const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed';
const SidebarContext = createContext(null);
const THEME_STORAGE_KEY = 'papercrate_theme_settings';
const DEFAULT_NEUTRAL_HUE = 180;
const DEFAULT_NEUTRAL_HUE = 145;
const DEFAULT_NEUTRAL_CHROMA = 0.15;
const DEFAULT_NEUTRAL_CONTRAST = 0.364;
const DEFAULT_THEME_MODE = 'system';
const THEME_MODES = ['system', 'light', 'dark'];
const loadInitialThemeSettings = () => {
const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, mode: DEFAULT_THEME_MODE };
const defaults = {
neutralHue: DEFAULT_NEUTRAL_HUE,
neutralChroma: DEFAULT_NEUTRAL_CHROMA,
neutralContrast: DEFAULT_NEUTRAL_CONTRAST,
mode: DEFAULT_THEME_MODE,
};
if (typeof window === 'undefined') {
if (typeof document !== 'undefined') {
const current = document.documentElement.style.getPropertyValue('--neutral-hue');
const root = document.documentElement;
const current = root.style.getPropertyValue('--neutral-hue');
const parsed = Number.parseInt(current, 10);
return {
neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
neutralChroma: defaults.neutralChroma,
neutralContrast: defaults.neutralContrast,
mode: defaults.mode,
};
}
@@ -32,13 +42,36 @@ const loadInitialThemeSettings = () => {
}
const root = document.documentElement;
const readNumberVar = (name, fallback) => {
const inlineValue = root.style.getPropertyValue(name);
const inlineParsed = Number.parseFloat(inlineValue);
if (!Number.isNaN(inlineParsed)) {
return inlineParsed;
}
if (typeof window !== 'undefined' && window.getComputedStyle) {
const computedValue = window.getComputedStyle(root).getPropertyValue(name);
const computedParsed = Number.parseFloat(computedValue);
if (!Number.isNaN(computedParsed)) {
return computedParsed;
}
}
return fallback;
};
const loadFromRoot = () => {
const current = root.style.getPropertyValue('--neutral-hue');
const parsed = Number.parseInt(current, 10);
return Number.isNaN(parsed) ? defaults.neutralHue : parsed;
return {
neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
neutralChroma: readNumberVar('--neutral-chroma', defaults.neutralChroma),
neutralContrast: readNumberVar('--neutral-contrast', defaults.neutralContrast),
};
};
let neutralHueValue = loadFromRoot();
const rootValues = loadFromRoot();
let neutralHueValue = rootValues.neutralHue;
let neutralChromaValue = rootValues.neutralChroma;
let neutralContrastValue = rootValues.neutralContrast;
let modeValue = defaults.mode;
const composite = window.localStorage.getItem(THEME_STORAGE_KEY);
@@ -49,6 +82,14 @@ const loadInitialThemeSettings = () => {
if (!Number.isNaN(storedHue)) {
neutralHueValue = storedHue;
}
const storedChroma = Number.parseFloat(parsed?.neutralChroma);
if (!Number.isNaN(storedChroma)) {
neutralChromaValue = Math.min(Math.max(storedChroma, 0), 1);
}
const storedContrast = Number.parseFloat(parsed?.neutralContrast);
if (!Number.isNaN(storedContrast)) {
neutralContrastValue = Math.min(Math.max(storedContrast, 0), 1);
}
const storedMode = parsed?.mode;
if (THEME_MODES.includes(storedMode)) {
modeValue = storedMode;
@@ -70,7 +111,12 @@ const loadInitialThemeSettings = () => {
}
}
return { neutralHue: neutralHueValue, mode: modeValue };
return {
neutralHue: neutralHueValue,
neutralChroma: neutralChromaValue,
neutralContrast: neutralContrastValue,
mode: modeValue,
};
};
const loadInitialCollapsedState = (defaultValue) => {
@@ -93,8 +139,11 @@ const loadInitialCollapsedState = (defaultValue) => {
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
const [sidebarSuppressedState, setSidebarSuppressedState] = useState(false);
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma);
const [neutralContrast, setNeutralContrastState] = useState(initialTheme.neutralContrast);
const [themeMode, setThemeModeState] = useState(initialTheme.mode);
useEffect(() => {
@@ -104,6 +153,20 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`);
}, [neutralHue]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.style.setProperty('--neutral-chroma', String(neutralChroma));
}, [neutralChroma]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.style.setProperty('--neutral-contrast', String(neutralContrast));
}, [neutralContrast]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
@@ -121,14 +184,19 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
return;
}
try {
const payload = JSON.stringify({ neutralHue, mode: themeMode });
const payload = JSON.stringify({
neutralHue,
neutralChroma,
neutralContrast,
mode: themeMode,
});
window.localStorage.setItem(THEME_STORAGE_KEY, payload);
window.localStorage.removeItem('papercrate_neutral_hue');
window.localStorage.removeItem('papercrate_theme_mode');
} catch (error) {
console.warn('[theme] failed to persist theme settings', error);
}
}, [neutralHue, themeMode]);
}, [neutralHue, neutralChroma, neutralContrast, themeMode]);
const setNeutralHue = useCallback((value) => {
setNeutralHueState((prev) => {
@@ -144,6 +212,42 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
});
}, []);
const setNeutralChroma = useCallback((value) => {
setNeutralChromaState((prev) => {
if (value === '' || value === null || typeof value === 'undefined') {
return DEFAULT_NEUTRAL_CHROMA;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return prev;
}
const clamped = Math.min(Math.max(parsed, 0), 1);
return Math.round(clamped * 1000) / 1000;
});
}, []);
const resetNeutralChroma = useCallback(() => {
setNeutralChromaState(DEFAULT_NEUTRAL_CHROMA);
}, []);
const setNeutralContrast = useCallback((value) => {
setNeutralContrastState((prev) => {
if (value === '' || value === null || typeof value === 'undefined') {
return DEFAULT_NEUTRAL_CONTRAST;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
return prev;
}
const clamped = Math.min(Math.max(parsed, 0), 1);
return Math.round(clamped * 1000) / 1000;
});
}, []);
const resetNeutralContrast = useCallback(() => {
setNeutralContrastState(DEFAULT_NEUTRAL_CONTRAST);
}, []);
const resetNeutralHue = useCallback(() => {
setNeutralHueState(DEFAULT_NEUTRAL_HUE);
}, []);
@@ -180,25 +284,51 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
setCollapsedState(Boolean(value));
}, []);
const setSidebarSuppressed = useCallback((value) => {
if (typeof value === 'function') {
setSidebarSuppressedState((prev) => Boolean(value(prev)));
return;
}
setSidebarSuppressedState(Boolean(value));
}, []);
const contextValue = useMemo(
() => ({
collapsed,
setCollapsed,
sidebarSuppressed: sidebarSuppressedState,
setSidebarSuppressed,
neutralHue,
setNeutralHue,
neutralChroma,
setNeutralChroma,
neutralContrast,
setNeutralContrast,
resetNeutralHue,
resetNeutralChroma,
resetNeutralContrast,
themeMode,
setThemeMode,
cycleThemeMode,
themeModes: THEME_MODES,
defaultNeutralHue: DEFAULT_NEUTRAL_HUE,
defaultNeutralChroma: DEFAULT_NEUTRAL_CHROMA,
defaultNeutralContrast: DEFAULT_NEUTRAL_CONTRAST,
}),
[
collapsed,
setCollapsed,
sidebarSuppressedState,
setSidebarSuppressed,
neutralHue,
setNeutralHue,
neutralChroma,
setNeutralChroma,
neutralContrast,
setNeutralContrast,
resetNeutralHue,
resetNeutralChroma,
resetNeutralContrast,
themeMode,
setThemeMode,
cycleThemeMode,
@@ -217,8 +347,11 @@ export const useSidebarContext = () => {
};
export const useSidebarControls = () => {
const { setCollapsed } = useSidebarContext();
const openSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
const { setCollapsed, setSidebarSuppressed } = useSidebarContext();
const openSidebar = useCallback(() => {
setSidebarSuppressed(false);
setCollapsed(false);
}, [setCollapsed, setSidebarSuppressed]);
const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]);
return { openSidebar, closeSidebar };
};
+41 -36
View File
@@ -1,26 +1,32 @@
: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;
--neutral-hue: 145deg;
--surface-hue-shift: 6deg;
--foreground-hue-offset: 20deg;
--accent-hue-offset: 0deg;
--dark-hue-offset: 40deg;
--dark-foreground-hue-offset: 20deg;
--neutral-chroma: 0.15;
--neutral-contrast: 0.5;
--neutral-contrast-amount: calc(0.85 + var(--neutral-contrast) * 0.55);
--neutral-contrast-baseline: 1.125;
--neutral-contrast-delta: calc(var(--neutral-contrast-amount) - var(--neutral-contrast-baseline));
--neutral-chroma-scale: calc(clamp(0.0001, var(--neutral-chroma), 1) / 0.1);
/* --- 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)));
--bg: oklch(calc(0.97 + var(--neutral-contrast-delta) * 0.12) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift)));
--surface: oklch(calc(0.985 + var(--neutral-contrast-delta) * 0.1) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift)));
--surface-subtle: oklch(calc(0.96 + var(--neutral-contrast-delta) * 0.16) calc(var(--neutral-chroma) * 0.002) calc(var(--neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(calc(0.32 - var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--muted: oklch(calc(0.54 - var(--neutral-contrast-delta) * 0.05) calc(var(--neutral-chroma) * 0.005) calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border));
--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)));
--sidebar-fg: oklch(calc(0.56 + var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset)));
--border: oklch(calc(0.92 + var(--neutral-contrast-delta) * 0.15) calc(var(--neutral-chroma) * 0.0015) 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)));
--accent: oklch(0.61 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset)));
--accent-hover: oklch(0.70 calc(0.12 * var(--neutral-chroma-scale)) 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%);
@@ -94,7 +100,8 @@
font-size: 15px;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--detail-panel-width: 30em;
--detail-panel-width: calc(100vw / 3);
--sidebar-width: 20em;
--documents-grid-title-size: 0.8rem;
}
@@ -107,18 +114,17 @@
--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));
--bg: oklch(calc(0.18 - var(--neutral-contrast-delta) * 0.015) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--surface: oklch(calc(0.22 - var(--neutral-contrast-delta) * 0.012) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--surface-subtle: oklch(calc(0.26 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(calc(0.9 + var(--neutral-contrast-delta) * 0.02) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue));
--muted: oklch(calc(0.72 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue));
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
--sidebar-fg: oklch(calc(0.78 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue));
--border: oklch(calc(0.33 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.03) 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)));
--accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset)));
--accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) 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%);
@@ -185,18 +191,17 @@
--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));
--bg: oklch(calc(0.5 - 0.35 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--surface: oklch(calc(0.5 - 0.31 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--surface-subtle: oklch(calc(0.5 - 0.27 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift)));
--fg: oklch(calc(0.5 + 0.39 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue));
--muted: oklch(calc(0.5 + 0.22 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue));
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
--sidebar-fg: oklch(calc(0.5 + 0.28 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue));
--border: oklch(calc(0.5 - 0.17 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.03) 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)));
--accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset)));
--accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) 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%);
+125 -2
View File
@@ -12,6 +12,48 @@
display: flex;
flex-direction: column;
z-index: 1000000;
overflow: visible;
}
.detail-panel--resizing {
cursor: col-resize;
user-select: none;
}
.detail-panel__resize-handle {
position: absolute;
top: 0;
left: -0.4rem;
width: 0.8rem;
height: 100%;
border: none;
background: transparent;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
}
.detail-panel__resize-handle span {
width: 2px;
height: 32px;
border-radius: 999px;
background: transparent;
transition: background 120ms ease;
}
.detail-panel__resize-handle:hover span,
.detail-panel__resize-handle:focus-visible span,
.detail-panel__resize-handle.is-active span {
background: var(--accent);
}
.detail-panel__resize-handle:focus-visible {
outline: 2px solid color-mix(in oklch, var(--accent) 60%, transparent);
outline-offset: 2px;
border-radius: 999px;
}
.panel-header {
@@ -489,6 +531,88 @@
gap: 0.25rem;
}
.document-summary--compact {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.document-summary__title-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.document-summary__meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.document-summary__meta-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.document-summary__meta-label {
color: var(--muted);
}
.document-summary__meta-value {
color: var(--fg);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.document-summary__meta-value .detail-meta__value {
font-size: inherit;
color: inherit;
}
.document-summary__section {
padding: 0;
}
.tag-list.document-summary__tags,
.correspondent-list.document-summary__correspondents {
margin-top: 0.25rem;
}
.document-summary__details {
}
.document-summary__details-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.document-summary__details-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.document-summary__details-row dt {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
word-break: break-word;
}
.document-summary__details-row dd {
margin: 0;
font-size: 0.95rem;
word-break: break-word;
}
.detail-panel .doc-title-edit,
.document-summary .doc-title-edit {
display: flex;
@@ -773,7 +897,7 @@
.detail-panel dt {
font-weight: 600;
margin-top: 0.8rem;
margin-top: 0;
}
.detail-panel dd {
@@ -786,7 +910,6 @@
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
align-items: center;
}
+1 -1
View File
@@ -79,7 +79,7 @@
justify-content: center;
flex-wrap: nowrap;
gap: 0.75rem;
z-index: 2000000;
z-index: 950000;
}
.panel-floating__label {
+40 -17
View File
@@ -43,7 +43,7 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 1rem 1rem;
padding: 0.5rem 1rem 0.5rem;
overflow: auto;
}
@@ -53,13 +53,12 @@
flex-direction: column;
justify-content: center;
align-items: center;
flex: 2 2 auto;
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
flex: 1;
min-height: 0;
order: -1;
}
.document-viewer--stacked .document-viewer__details-pane {
flex: 1 1 auto;
overflow: visible;
order: 0;
}
@@ -191,6 +190,15 @@
min-height: 0;
}
.document-viewer__tabs-wrapper--bottom .document-viewer__tabpanes {
order: 1;
}
.document-viewer__tabs-wrapper--bottom .document-viewer__tabs {
order: 2;
margin-top: 0.75rem;
}
.document-viewer__section {
display: flex;
flex-direction: column;
@@ -270,28 +278,37 @@
font-size: 0.85rem;
line-height: 1.35;
overflow: auto;
word-break: break-word;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.document-viewer__tabs {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--outline-subtle);
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 999px;
background: var(--surface-overlay, var(--surface));
border: 1px solid var(--outline-subtle);
box-shadow: var(--panel-shadow-soft, none);
}
.document-viewer__tab {
appearance: none;
border: none;
background: transparent;
padding: 0.4rem 0.75rem;
padding: 0.35rem 0.9rem;
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;
background-color 120ms ease;
border-radius: 999px;
flex: 1;
text-align: center;
}
.document-viewer__tab:hover,
@@ -300,8 +317,8 @@
}
.document-viewer__tab.is-active {
color: var(--accent);
border-color: var(--accent);
color: var(--accent-strong, var(--accent));
background: var(--accent-soft);
}
.document-viewer__tabpanes {
@@ -310,6 +327,10 @@
display: flex;
}
.document-viewer--stacked .document-viewer__tabpanes {
overflow: auto;
}
.document-viewer__tabpanes--single {
flex: 1;
min-height: 0;
@@ -347,7 +368,7 @@
display: flex;
position: relative;
overflow: hidden;
align-items: flex-start;
align-items: stretch;
justify-content: flex-start;
max-height: 100%;
grid-area: viewport;
@@ -371,12 +392,11 @@
}
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
max-height: 100%;
height: 100%;
}
.document-viewer--stacked .document-viewer__object--image {
height: auto;
max-height: 100%;
}
.document-viewer__unsupported {
@@ -410,3 +430,6 @@
width: 1rem;
height: 1rem;
}
.document-viewer__summary-tab-content {
padding-top: 0.5rem;
}
+3 -3
View File
@@ -14,7 +14,7 @@
position: relative;
}
.documents-main--sidebar-collapsed {
.documents-main--sidebar-hidden {
position: relative;
}
@@ -30,7 +30,7 @@
box-shadow: -12px 0 24px -12px var(--shadow-faint);
}
.documents-main:not(.documents-main--sidebar-collapsed) .main-content {
.documents-main:not(.documents-main--sidebar-hidden) .main-content {
border-left: 1px solid var(--border);
}
@@ -77,7 +77,7 @@
}
.documents-main--sidebar-collapsed {
.documents-main--sidebar-hidden {
position: relative;
grid-template-columns: auto minmax(0, 1fr);
}
+46 -3
View File
@@ -128,10 +128,53 @@
color: var(--sidebar-fg);
display: flex;
flex-direction: column;
width: 20em;
max-width: 20em;
flex: 0 0 20em;
width: min(100vw, var(--sidebar-width));
max-width: min(100vw, var(--sidebar-width));
flex: 0 0 var(--sidebar-width);
background: var(--bg);
position: relative;
}
.sidebar--resizing {
cursor: col-resize;
user-select: none;
}
.sidebar__resize-handle {
position: absolute;
top: 0;
right: -0.4rem;
width: 0.8rem;
height: 100%;
border: none;
background: transparent;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
z-index: 1;
}
.sidebar__resize-handle span {
width: 2px;
height: 32px;
border-radius: 999px;
background: transparent;
transition: background 120ms ease;
}
.sidebar__resize-handle:hover span,
.sidebar__resize-handle:focus-visible span,
.sidebar__resize-handle.is-active span {
background: var(--accent);
}
.sidebar__resize-handle:focus-visible {
outline: 2px solid color-mix(in oklch, var(--accent) 60%, transparent);
outline-offset: 2px;
border-radius: 999px;
}
.sidebar__body {
@@ -6,12 +6,17 @@
cursor: grab;
touch-action: none;
transform-origin: center center;
transition: box-shadow 0.16s ease;
transition:
opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.55s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.16s ease;
outline: none;
will-change: transform;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
filter: blur(0px) grayscale(0%);
opacity: 1;
}
.desk-item__body {
@@ -41,10 +46,9 @@
}
.desk-item.is-filtered-out {
opacity: 0.12;
opacity: 0;
pointer-events: none;
filter: blur(15px) grayscale(100%);
transition: opacity 0.6s ease, filter 0.28s ease;
filter: blur(18px) grayscale(100%);
z-index: 0;
}