Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
z-index: 2000000;
|
||||
z-index: 950000;
|
||||
}
|
||||
|
||||
.panel-floating__label {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user