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';
|
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||||
|
|
||||||
const DocumentsLayout = ({ sidebarProps, children }) => {
|
const DocumentsLayout = ({ sidebarProps, children }) => {
|
||||||
const { collapsed } = useSidebarContext();
|
const { collapsed, sidebarSuppressed } = useSidebarContext();
|
||||||
|
const sidebarHidden = collapsed || sidebarSuppressed;
|
||||||
return (
|
return (
|
||||||
<main className={`documents-main${collapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
<main className={`documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}`}>
|
||||||
{!collapsed ? <Sidebar {...sidebarProps} /> : null}
|
{!sidebarHidden ? <Sidebar {...sidebarProps} /> : null}
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
import DocumentsLayout from './DocumentsLayout';
|
import DocumentsLayout from './DocumentsLayout';
|
||||||
@@ -28,9 +28,105 @@ const DocumentsRouteContent = () => {
|
|||||||
notifyApiError,
|
notifyApiError,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
const navigate = useNavigate();
|
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(
|
const sidebarPropsWithActions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -67,8 +163,10 @@ const DocumentsRouteContent = () => {
|
|||||||
navigate(target);
|
navigate(target);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
|
||||||
|
|
||||||
const { surface } = useWorkspaceSurface({
|
const { surface } = useWorkspaceSurface({
|
||||||
sidebarCollapsed,
|
sidebarHidden,
|
||||||
onExpandSidebar: expandSidebar,
|
onExpandSidebar: expandSidebar,
|
||||||
documentsTableProps,
|
documentsTableProps,
|
||||||
detailPanelProps,
|
detailPanelProps,
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ const AppStateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/auth/tenants');
|
const { data } = await api.get('/tenants');
|
||||||
if (!abort) {
|
if (!abort) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SET_TENANTS',
|
type: 'SET_TENANTS',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const useDocumentPreview = ({
|
|||||||
routeDocumentId,
|
routeDocumentId,
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
setDocuments,
|
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
assetManager,
|
assetManager,
|
||||||
api,
|
api,
|
||||||
@@ -17,6 +16,7 @@ const useDocumentPreview = ({
|
|||||||
setActivePreviewId,
|
setActivePreviewId,
|
||||||
}) => {
|
}) => {
|
||||||
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||||
|
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
|
||||||
const previewInflightRef = useRef(new Map());
|
const previewInflightRef = useRef(new Map());
|
||||||
const previewReturnPathRef = useRef(null);
|
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(
|
const ensurePreviewUrl = useCallback(
|
||||||
async (documentId, { force = false } = {}) => {
|
async (documentId, { force = false } = {}) => {
|
||||||
if (!documentId) return null;
|
if (!documentId) return null;
|
||||||
@@ -116,12 +145,16 @@ const useDocumentPreview = ({
|
|||||||
throw new Error('Document metadata unavailable.');
|
throw new Error('Document metadata unavailable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocuments((prev) => {
|
const existsInDocuments = documents.some((item) => item.id === doc.id);
|
||||||
if (prev.some((item) => item.id === doc.id)) {
|
const existsInSearch = Array.isArray(searchResults)
|
||||||
return prev;
|
? searchResults.some((item) => item.id === doc.id)
|
||||||
}
|
: false;
|
||||||
return [doc, ...prev];
|
|
||||||
});
|
if (existsInDocuments || existsInSearch) {
|
||||||
|
removeCachedPreviewDocument(doc.id);
|
||||||
|
} else {
|
||||||
|
cachePreviewDocument(doc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!previewReturnPathRef.current) {
|
if (!previewReturnPathRef.current) {
|
||||||
@@ -138,10 +171,11 @@ const useDocumentPreview = ({
|
|||||||
searchResults,
|
searchResults,
|
||||||
documents,
|
documents,
|
||||||
assetManager,
|
assetManager,
|
||||||
setDocuments,
|
|
||||||
ensurePreviewUrl,
|
ensurePreviewUrl,
|
||||||
setActivePreviewId,
|
setActivePreviewId,
|
||||||
api,
|
api,
|
||||||
|
cachePreviewDocument,
|
||||||
|
removeCachedPreviewDocument,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,8 +241,33 @@ const useDocumentPreview = ({
|
|||||||
};
|
};
|
||||||
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
}, [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 {
|
return {
|
||||||
previewEntries,
|
previewEntries,
|
||||||
|
previewDocuments,
|
||||||
ensurePreviewUrl,
|
ensurePreviewUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
|||||||
import createDesktopSurface from '../desktop/createDesktopSurface';
|
import createDesktopSurface from '../desktop/createDesktopSurface';
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
export const useWorkspaceSurface = ({
|
||||||
sidebarCollapsed,
|
sidebarHidden,
|
||||||
onExpandSidebar,
|
onExpandSidebar,
|
||||||
documentsTableProps,
|
documentsTableProps,
|
||||||
detailPanelProps,
|
detailPanelProps,
|
||||||
@@ -25,7 +25,7 @@ export const useWorkspaceSurface = ({
|
|||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
}) => {
|
}) => {
|
||||||
const renderSidebarToggle = useCallback(() => {
|
const renderSidebarToggle = useCallback(() => {
|
||||||
if (!sidebarCollapsed) {
|
if (!sidebarHidden) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +39,7 @@ export const useWorkspaceSurface = ({
|
|||||||
<SidebarExpandIcon />
|
<SidebarExpandIcon />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}, [sidebarCollapsed, onExpandSidebar]);
|
}, [sidebarHidden, onExpandSidebar]);
|
||||||
|
|
||||||
const documentsSurface = useMemo(() => {
|
const documentsSurface = useMemo(() => {
|
||||||
if (!documentsTableProps) {
|
if (!documentsTableProps) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const useDetailWorkspace = ({
|
const useDetailWorkspace = ({
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
previewDocuments,
|
||||||
selectionOrder,
|
selectionOrder,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
@@ -195,8 +196,10 @@ const useDetailWorkspace = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const pool = searchResults ?? documents;
|
const pool = searchResults ?? documents;
|
||||||
return pool.find((doc) => doc.id === previewDocumentId) || null;
|
return pool.find((doc) => doc.id === previewDocumentId)
|
||||||
}, [previewDocumentId, searchResults, documents]);
|
|| previewDocuments?.get?.(previewDocumentId)
|
||||||
|
|| null;
|
||||||
|
}, [previewDocumentId, searchResults, documents, previewDocuments]);
|
||||||
|
|
||||||
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
|
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 DocumentSummarySection from './DocumentSummarySection';
|
||||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||||
|
|
||||||
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
|
|||||||
resetKey = null,
|
resetKey = null,
|
||||||
classNamePrefix = 'document-info',
|
classNamePrefix = 'document-info',
|
||||||
hideTabNavWhenSingle = true,
|
hideTabNavWhenSingle = true,
|
||||||
|
summaryPlacement = 'inline',
|
||||||
|
summaryTabLabel = 'Summary',
|
||||||
|
summaryTabId = 'summary',
|
||||||
|
leadingTabs = [],
|
||||||
|
trailingTabs = [],
|
||||||
|
tabsPlacement = 'top',
|
||||||
|
summaryLayout = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
const base = classNamePrefix;
|
const base = classNamePrefix;
|
||||||
|
|
||||||
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
|
|||||||
};
|
};
|
||||||
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
|
}, [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 visibleTabs = useMemo(() => {
|
||||||
const tabsList = [];
|
const tabsList = [];
|
||||||
|
|
||||||
tabsList.push({
|
if (normalizedLeadingTabs.length) {
|
||||||
id: 'details',
|
tabsList.push(...normalizedLeadingTabs);
|
||||||
label: detailsTabLabel,
|
}
|
||||||
render: () => (
|
|
||||||
<section className={`${base}__section`}>
|
if (summaryTab) {
|
||||||
{metadataItems.length ? (
|
tabsList.push(summaryTab);
|
||||||
<dl className={`${base}__section-list`}>
|
}
|
||||||
{metadataItems.map(({ label, value }) => (
|
|
||||||
<div className={`${base}__section-item`} key={label}>
|
if (summaryPlacement !== 'tabs') {
|
||||||
<dt>{label}</dt>
|
tabsList.push({
|
||||||
<dd>{value || '—'}</dd>
|
id: 'details',
|
||||||
</div>
|
label: detailsTabLabel,
|
||||||
))}
|
render: () => renderDetailsSection(),
|
||||||
</dl>
|
});
|
||||||
) : (
|
}
|
||||||
<p className={`${base}__section-placeholder`}>No details available.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (showContentTab && contentConfig) {
|
if (showContentTab && contentConfig) {
|
||||||
tabsList.push({
|
tabsList.push({
|
||||||
@@ -183,30 +251,38 @@ const DocumentInfoPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (metadataPayload) {
|
if (metadataPayload) {
|
||||||
tabsList.push({
|
tabsList.push({
|
||||||
id: 'metadata',
|
id: 'metadata',
|
||||||
label: metadataTabLabel,
|
label: metadataTabLabel,
|
||||||
render: () => (
|
render: () => (
|
||||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||||
<pre className={`${base}__metadata-json`}>
|
<pre className={`${base}__metadata-json`}>
|
||||||
{JSON.stringify(metadataPayload, null, 2)}
|
{JSON.stringify(metadataPayload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedTrailingTabs.length) {
|
||||||
|
tabsList.push(...normalizedTrailingTabs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabsList;
|
return tabsList;
|
||||||
}, [
|
}, [
|
||||||
base,
|
base,
|
||||||
detailsTabLabel,
|
detailsTabLabel,
|
||||||
metadataItems,
|
|
||||||
contentConfig,
|
contentConfig,
|
||||||
contentEnabled,
|
contentEnabled,
|
||||||
contentState,
|
contentState,
|
||||||
metadataPayload,
|
metadataPayload,
|
||||||
metadataTabLabel,
|
metadataTabLabel,
|
||||||
showContentTab,
|
showContentTab,
|
||||||
|
summaryTab,
|
||||||
|
normalizedLeadingTabs,
|
||||||
|
normalizedTrailingTabs,
|
||||||
|
summaryPlacement,
|
||||||
|
renderDetailsSection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fallbackTabId = useMemo(() => {
|
const fallbackTabId = useMemo(() => {
|
||||||
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
|
|||||||
if (!isControlled) {
|
if (!isControlled) {
|
||||||
setUncontrolledTab(fallbackTabId);
|
setUncontrolledTab(fallbackTabId);
|
||||||
}
|
}
|
||||||
}, [fallbackTabId, resetKey, isControlled]);
|
}, [fallbackTabId, isControlled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
|
||||||
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
|
|||||||
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
|
||||||
const shouldHideNav = hideTabNavWhenSingle && singleTab;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentSummarySection
|
{summaryNode}
|
||||||
document={document}
|
|
||||||
{...summaryProps}
|
|
||||||
/>
|
|
||||||
{shouldHideNav ? (
|
{shouldHideNav ? (
|
||||||
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
|
||||||
<div className={`${base}__tabpanel`}>
|
<div className={`${base}__tabpanel`}>
|
||||||
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${base}__tabs-wrapper`}>
|
<div className={tabsWrapperClass}>
|
||||||
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
|
{tabsPlacement !== 'bottom' ? tabNav : null}
|
||||||
{visibleTabs.map((tab) => (
|
{tabsPlacement === 'bottom' ? tabPanels : null}
|
||||||
<button
|
{tabsPlacement === 'bottom' ? tabNav : null}
|
||||||
key={tab.id}
|
{tabsPlacement !== 'bottom' ? tabPanels : null}
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
|
|||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
onUpdateTitle,
|
onUpdateTitle,
|
||||||
onUpdateIssued
|
onUpdateIssued,
|
||||||
|
layout = 'default',
|
||||||
|
detailItems = [],
|
||||||
}) => {
|
}) => {
|
||||||
|
const isCompactLayout = layout === 'compact';
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return {
|
return {
|
||||||
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<div className="document-summary">
|
<div className="document-summary">
|
||||||
<div className="doc-title-row">
|
<div className="doc-title-row">
|
||||||
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
|
|||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={startTitleEdit}
|
onClick={startTitleEdit}
|
||||||
aria-label="Edit title"
|
aria-label="Edit title"
|
||||||
title="Edit title"
|
title="Edit title"
|
||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
|
||||||
<div className="detail-meta">
|
<div className="detail-meta">
|
||||||
<div className="detail-meta__row">
|
<div className="detail-meta__row">
|
||||||
<span className="detail-meta__label">Issued:</span>
|
<span className="detail-meta__label">Issued:</span>
|
||||||
{editableIssued && isIssuedEditing ? (
|
{issuedDisplay}
|
||||||
<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}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
||||||
|
|
||||||
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TagSection
|
{renderTags()}
|
||||||
tags={resolvedTags}
|
|
||||||
onRemove={
|
|
||||||
onTagRemove
|
|
||||||
? (tag) => onTagRemove(document.id, tag.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onAdd={
|
|
||||||
onTagAdd
|
|
||||||
? ({ value, option }) => onTagAdd(document, value, { option })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
datalistOptions={tagOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CorrespondentSection
|
{renderCorrespondents()}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,34 @@ const DocumentsPanel = ({
|
|||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
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 entries = useMemo(() => {
|
||||||
const list = [];
|
const list = [];
|
||||||
if (!showingSearchResults) {
|
if (!showingSearchResults) {
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ const useDocumentsWorkspace = ({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
previewEntries,
|
previewEntries,
|
||||||
|
previewDocuments,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
@@ -337,7 +338,6 @@ const useDocumentsWorkspace = ({
|
|||||||
routeDocumentId: previewDocumentId,
|
routeDocumentId: previewDocumentId,
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
setDocuments,
|
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
assetManager,
|
assetManager,
|
||||||
api,
|
api,
|
||||||
@@ -410,8 +410,13 @@ const useDocumentsWorkspace = ({
|
|||||||
if (Array.isArray(searchResults)) {
|
if (Array.isArray(searchResults)) {
|
||||||
push(searchResults);
|
push(searchResults);
|
||||||
}
|
}
|
||||||
|
previewDocuments.forEach((doc, id) => {
|
||||||
|
if (doc && id && !map.has(id)) {
|
||||||
|
map.set(id, doc);
|
||||||
|
}
|
||||||
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [documents, searchResults]);
|
}, [documents, searchResults, previewDocuments]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
@@ -1158,6 +1163,7 @@ const useDocumentsWorkspace = ({
|
|||||||
} = useDetailWorkspace({
|
} = useDetailWorkspace({
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
previewDocuments,
|
||||||
focusedDocumentId,
|
focusedDocumentId,
|
||||||
selectionOrder,
|
selectionOrder,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ const useTenantManager = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (refreshOnly) {
|
if (refreshOnly) {
|
||||||
const { data } = await apiClient.get('/auth/tenants');
|
const { data } = await apiClient.get('/tenants');
|
||||||
appDispatch({
|
appDispatch({
|
||||||
type: 'SET_TENANTS',
|
type: 'SET_TENANTS',
|
||||||
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
|
tenants: Array.isArray(data) ? data : [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||||
import { DownloadIcon } from '../ui/icons';
|
import { DownloadIcon } from '../ui/icons';
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ const DocumentViewerLayout = ({
|
|||||||
defaultTabId = 'details',
|
defaultTabId = 'details',
|
||||||
infoPanelProps = {},
|
infoPanelProps = {},
|
||||||
previewLoadingMessage = 'Preparing preview…',
|
previewLoadingMessage = 'Preparing preview…',
|
||||||
|
layoutMode = 'split',
|
||||||
}) => {
|
}) => {
|
||||||
|
const isStacked = layoutMode === 'stacked';
|
||||||
|
|
||||||
const previewContent = useMemo(() => {
|
const previewContent = useMemo(() => {
|
||||||
if (!document || !previewEntry?.url) {
|
if (!document || !previewEntry?.url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -74,30 +77,65 @@ const DocumentViewerLayout = ({
|
|||||||
);
|
);
|
||||||
}, [previewEntry, document]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="document-viewer__details-pane">
|
{detailsPane}
|
||||||
<div className="document-viewer__details">
|
{viewportPane}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,77 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
|||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import DocumentViewerLayout from './DocumentViewerLayout';
|
import DocumentViewerLayout from './DocumentViewerLayout';
|
||||||
import useViewerLayoutMode from './useViewerLayoutMode';
|
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 = ({
|
export const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
@@ -99,6 +170,7 @@ const DocumentViewerPanel = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isSidebarVariant = variant === 'sidebar';
|
const isSidebarVariant = variant === 'sidebar';
|
||||||
|
const { setSidebarSuppressed } = useSidebarContext();
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() => sortCorrespondents(document?.correspondents || []),
|
() => sortCorrespondents(document?.correspondents || []),
|
||||||
[document],
|
[document],
|
||||||
@@ -239,6 +311,174 @@ const DocumentViewerPanel = ({
|
|||||||
|
|
||||||
const panelRef = useRef(null);
|
const panelRef = useRef(null);
|
||||||
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
|
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
|
const viewerClassName = isStackedLayout
|
||||||
? 'document-viewer document-viewer--stacked'
|
? 'document-viewer document-viewer--stacked'
|
||||||
@@ -403,6 +643,18 @@ const DocumentViewerPanel = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: 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 = (
|
const loadingSection = (
|
||||||
<div className="document-viewer-panel__body">
|
<div className="document-viewer-panel__body">
|
||||||
<section className="document-viewer document-viewer--loading">
|
<section className="document-viewer document-viewer--loading">
|
||||||
@@ -432,6 +684,7 @@ const DocumentViewerPanel = ({
|
|||||||
metadataPayload={metadataPayload}
|
metadataPayload={metadataPayload}
|
||||||
contentTabConfig={contentTabConfig}
|
contentTabConfig={contentTabConfig}
|
||||||
previewLoadingMessage="Loading preview…"
|
previewLoadingMessage="Loading preview…"
|
||||||
|
layoutMode={isStackedLayout ? 'stacked' : 'split'}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,7 +712,8 @@ const DocumentViewerPanel = ({
|
|||||||
if (isSidebarVariant) {
|
if (isSidebarVariant) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="detail-panel panel" ref={panelRef}>
|
<aside className={`detail-panel panel${isResizingPanel ? ' detail-panel--resizing' : ''}`} ref={panelRef}>
|
||||||
|
{resizeHandle}
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
leading={headerLeadingContent}
|
leading={headerLeadingContent}
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useLayoutEffect, useState } from 'react';
|
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;
|
export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4;
|
||||||
const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style';
|
const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style';
|
||||||
|
|
||||||
@@ -25,14 +24,10 @@ const ensurePortraitRatioStyle = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const computeStackedLayoutBreakpoint = () => {
|
const computeStackedLayoutBreakpoint = () => {
|
||||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900;
|
if (typeof window === 'undefined') {
|
||||||
const portraitViewportWidth = viewportHeight
|
return 900;
|
||||||
* DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO
|
}
|
||||||
* PORTRAIT_WIDTH_TO_HEIGHT;
|
return window.innerWidth / 2;
|
||||||
const detailsColumnWidth = 320; // px ~ 20rem for metadata & tabs
|
|
||||||
const gutterAllowance = 48; // padding + grid gap
|
|
||||||
|
|
||||||
return portraitViewportWidth + detailsColumnWidth + gutterAllowance;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useViewerLayoutMode = (ref, dependency) => {
|
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 { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
@@ -24,6 +24,80 @@ import useFloatingMenu from '../ui/useFloatingMenu';
|
|||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { useSidebarContext } from './SidebarContext';
|
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 = ({
|
const FolderNode = ({
|
||||||
node,
|
node,
|
||||||
depth,
|
depth,
|
||||||
@@ -195,15 +269,152 @@ const Sidebar = ({
|
|||||||
neutralHue,
|
neutralHue,
|
||||||
setNeutralHue,
|
setNeutralHue,
|
||||||
resetNeutralHue,
|
resetNeutralHue,
|
||||||
|
neutralChroma,
|
||||||
|
setNeutralChroma,
|
||||||
|
resetNeutralChroma,
|
||||||
|
neutralContrast,
|
||||||
|
setNeutralContrast,
|
||||||
|
resetNeutralContrast,
|
||||||
themeMode,
|
themeMode,
|
||||||
cycleThemeMode,
|
cycleThemeMode,
|
||||||
themeModes,
|
themeModes,
|
||||||
|
sidebarSuppressed,
|
||||||
} = useSidebarContext();
|
} = useSidebarContext();
|
||||||
const uploadInputRef = useRef(null);
|
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(() => {
|
const handleCollapse = useCallback(() => {
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
}, [setCollapsed]);
|
}, [setCollapsed]);
|
||||||
const neutralHueInputId = useId();
|
const neutralHueInputId = useId();
|
||||||
|
const neutralChromaInputId = useId();
|
||||||
|
const neutralContrastInputId = useId();
|
||||||
const sortedCorrespondents = useMemo(() => {
|
const sortedCorrespondents = useMemo(() => {
|
||||||
if (!Array.isArray(correspondents)) {
|
if (!Array.isArray(correspondents)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -281,9 +492,11 @@ const Sidebar = ({
|
|||||||
[onUploadFiles, selectedFolder],
|
[onUploadFiles, selectedFolder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNeutralHueReset = useCallback(() => {
|
const handleThemeAdjustmentsReset = useCallback(() => {
|
||||||
resetNeutralHue();
|
resetNeutralHue();
|
||||||
}, [resetNeutralHue]);
|
resetNeutralChroma();
|
||||||
|
resetNeutralContrast();
|
||||||
|
}, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]);
|
||||||
|
|
||||||
const handleNeutralHueChange = useCallback(
|
const handleNeutralHueChange = useCallback(
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -300,6 +513,44 @@ const Sidebar = ({
|
|||||||
[resetNeutralHue, setNeutralHue],
|
[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 themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES;
|
||||||
const themeModeIndex = themeModeList.indexOf(themeMode);
|
const themeModeIndex = themeModeList.indexOf(themeMode);
|
||||||
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
|
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
|
||||||
@@ -336,9 +587,9 @@ const Sidebar = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={handleNeutralHueReset}
|
onClick={handleThemeAdjustmentsReset}
|
||||||
aria-label="Reset neutral hue"
|
aria-label="Reset theme adjustments"
|
||||||
title="Reset neutral hue"
|
title="Reset theme adjustments"
|
||||||
>
|
>
|
||||||
<RestoreIcon size={16} />
|
<RestoreIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -354,9 +605,38 @@ const Sidebar = ({
|
|||||||
step="1"
|
step="1"
|
||||||
value={neutralHue}
|
value={neutralHue}
|
||||||
onChange={(event) => handleNeutralHueChange(event.target.value)}
|
onChange={(event) => handleNeutralHueChange(event.target.value)}
|
||||||
|
onDoubleClick={resetNeutralHue}
|
||||||
/>
|
/>
|
||||||
<span className="menu__slider-value">{neutralHue}°</span>
|
<span className="menu__slider-value">{neutralHue}°</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
@@ -470,8 +750,25 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rootNode = folderNodes.get('root');
|
const rootNode = folderNodes.get('root');
|
||||||
|
const sidebarClassNames = ['sidebar'];
|
||||||
|
if (isResizingSidebar) {
|
||||||
|
sidebarClassNames.push('sidebar--resizing');
|
||||||
|
}
|
||||||
|
if (sidebarSuppressed) {
|
||||||
|
sidebarClassNames.push('sidebar--suppressed');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={uploadInputRef}
|
ref={uploadInputRef}
|
||||||
|
|||||||
@@ -12,19 +12,29 @@ const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed';
|
|||||||
const SidebarContext = createContext(null);
|
const SidebarContext = createContext(null);
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'papercrate_theme_settings';
|
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 DEFAULT_THEME_MODE = 'system';
|
||||||
const THEME_MODES = ['system', 'light', 'dark'];
|
const THEME_MODES = ['system', 'light', 'dark'];
|
||||||
|
|
||||||
const loadInitialThemeSettings = () => {
|
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 window === 'undefined') {
|
||||||
if (typeof document !== '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);
|
const parsed = Number.parseInt(current, 10);
|
||||||
return {
|
return {
|
||||||
neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
|
neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
|
||||||
|
neutralChroma: defaults.neutralChroma,
|
||||||
|
neutralContrast: defaults.neutralContrast,
|
||||||
mode: defaults.mode,
|
mode: defaults.mode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,13 +42,36 @@ const loadInitialThemeSettings = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement;
|
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 loadFromRoot = () => {
|
||||||
const current = root.style.getPropertyValue('--neutral-hue');
|
const current = root.style.getPropertyValue('--neutral-hue');
|
||||||
const parsed = Number.parseInt(current, 10);
|
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;
|
let modeValue = defaults.mode;
|
||||||
|
|
||||||
const composite = window.localStorage.getItem(THEME_STORAGE_KEY);
|
const composite = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
@@ -49,6 +82,14 @@ const loadInitialThemeSettings = () => {
|
|||||||
if (!Number.isNaN(storedHue)) {
|
if (!Number.isNaN(storedHue)) {
|
||||||
neutralHueValue = 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;
|
const storedMode = parsed?.mode;
|
||||||
if (THEME_MODES.includes(storedMode)) {
|
if (THEME_MODES.includes(storedMode)) {
|
||||||
modeValue = 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) => {
|
const loadInitialCollapsedState = (defaultValue) => {
|
||||||
@@ -93,8 +139,11 @@ const loadInitialCollapsedState = (defaultValue) => {
|
|||||||
|
|
||||||
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
||||||
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
|
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
|
||||||
|
const [sidebarSuppressedState, setSidebarSuppressedState] = useState(false);
|
||||||
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
|
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
|
||||||
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
|
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
|
||||||
|
const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma);
|
||||||
|
const [neutralContrast, setNeutralContrastState] = useState(initialTheme.neutralContrast);
|
||||||
const [themeMode, setThemeModeState] = useState(initialTheme.mode);
|
const [themeMode, setThemeModeState] = useState(initialTheme.mode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +153,20 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
|||||||
document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`);
|
document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`);
|
||||||
}, [neutralHue]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -121,14 +184,19 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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.setItem(THEME_STORAGE_KEY, payload);
|
||||||
window.localStorage.removeItem('papercrate_neutral_hue');
|
window.localStorage.removeItem('papercrate_neutral_hue');
|
||||||
window.localStorage.removeItem('papercrate_theme_mode');
|
window.localStorage.removeItem('papercrate_theme_mode');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[theme] failed to persist theme settings', error);
|
console.warn('[theme] failed to persist theme settings', error);
|
||||||
}
|
}
|
||||||
}, [neutralHue, themeMode]);
|
}, [neutralHue, neutralChroma, neutralContrast, themeMode]);
|
||||||
|
|
||||||
const setNeutralHue = useCallback((value) => {
|
const setNeutralHue = useCallback((value) => {
|
||||||
setNeutralHueState((prev) => {
|
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(() => {
|
const resetNeutralHue = useCallback(() => {
|
||||||
setNeutralHueState(DEFAULT_NEUTRAL_HUE);
|
setNeutralHueState(DEFAULT_NEUTRAL_HUE);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -180,25 +284,51 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
|||||||
setCollapsedState(Boolean(value));
|
setCollapsedState(Boolean(value));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setSidebarSuppressed = useCallback((value) => {
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
setSidebarSuppressedState((prev) => Boolean(value(prev)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSidebarSuppressedState(Boolean(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
collapsed,
|
collapsed,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
|
sidebarSuppressed: sidebarSuppressedState,
|
||||||
|
setSidebarSuppressed,
|
||||||
neutralHue,
|
neutralHue,
|
||||||
setNeutralHue,
|
setNeutralHue,
|
||||||
|
neutralChroma,
|
||||||
|
setNeutralChroma,
|
||||||
|
neutralContrast,
|
||||||
|
setNeutralContrast,
|
||||||
resetNeutralHue,
|
resetNeutralHue,
|
||||||
|
resetNeutralChroma,
|
||||||
|
resetNeutralContrast,
|
||||||
themeMode,
|
themeMode,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
cycleThemeMode,
|
cycleThemeMode,
|
||||||
themeModes: THEME_MODES,
|
themeModes: THEME_MODES,
|
||||||
defaultNeutralHue: DEFAULT_NEUTRAL_HUE,
|
defaultNeutralHue: DEFAULT_NEUTRAL_HUE,
|
||||||
|
defaultNeutralChroma: DEFAULT_NEUTRAL_CHROMA,
|
||||||
|
defaultNeutralContrast: DEFAULT_NEUTRAL_CONTRAST,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
collapsed,
|
collapsed,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
|
sidebarSuppressedState,
|
||||||
|
setSidebarSuppressed,
|
||||||
neutralHue,
|
neutralHue,
|
||||||
setNeutralHue,
|
setNeutralHue,
|
||||||
|
neutralChroma,
|
||||||
|
setNeutralChroma,
|
||||||
|
neutralContrast,
|
||||||
|
setNeutralContrast,
|
||||||
resetNeutralHue,
|
resetNeutralHue,
|
||||||
|
resetNeutralChroma,
|
||||||
|
resetNeutralContrast,
|
||||||
themeMode,
|
themeMode,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
cycleThemeMode,
|
cycleThemeMode,
|
||||||
@@ -217,8 +347,11 @@ export const useSidebarContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useSidebarControls = () => {
|
export const useSidebarControls = () => {
|
||||||
const { setCollapsed } = useSidebarContext();
|
const { setCollapsed, setSidebarSuppressed } = useSidebarContext();
|
||||||
const openSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
|
const openSidebar = useCallback(() => {
|
||||||
|
setSidebarSuppressed(false);
|
||||||
|
setCollapsed(false);
|
||||||
|
}, [setCollapsed, setSidebarSuppressed]);
|
||||||
const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]);
|
const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]);
|
||||||
return { openSidebar, closeSidebar };
|
return { openSidebar, closeSidebar };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
|
|
||||||
--neutral-hue: 180deg;
|
--neutral-hue: 145deg;
|
||||||
--foreground-hue-offset: 10deg;
|
--surface-hue-shift: 6deg;
|
||||||
--accent-hue-offset: -20deg;
|
--foreground-hue-offset: 20deg;
|
||||||
--dark-hue-offset: 30deg;
|
--accent-hue-offset: 0deg;
|
||||||
--dark-foreground-hue-offset: 10deg;
|
--dark-hue-offset: 40deg;
|
||||||
--surface-hue-shift: -4deg;
|
--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 --- */
|
/* --- Neutrals --- */
|
||||||
--bg: oklch(0.97 0.001 calc(var(--neutral-hue) + var(--surface-hue-shift)));
|
--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(0.985 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(0.96 0.002 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(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
--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(0.54 0.008 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));
|
--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)));
|
--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(0.92 0.002 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 (primary) --- */
|
||||||
--accent: oklch(0.61 0.16 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 0.12 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);
|
--on-accent: oklch(1 0 0);
|
||||||
|
|
||||||
--folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%);
|
--folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%);
|
||||||
@@ -94,7 +100,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
--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;
|
--documents-grid-title-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,18 +114,17 @@
|
|||||||
|
|
||||||
--dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset));
|
--dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset));
|
||||||
--dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset));
|
--dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset));
|
||||||
|
--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)));
|
||||||
--bg: oklch(0.15 0.01 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: oklch(0.19 0.012 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)));
|
||||||
--surface-subtle: oklch(0.23 0.012 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));
|
||||||
--fg: oklch(0.89 0.015 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: oklch(0.72 0.02 var(--dark-foreground-hue));
|
|
||||||
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
|
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
|
||||||
--sidebar-fg: oklch(0.78 0.02 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(0.33 0.01 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: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) 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-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));
|
--on-accent: oklch(0.15 0.015 var(--dark-foreground-hue));
|
||||||
|
|
||||||
--folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%);
|
--folder-icon-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-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset));
|
||||||
--dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset));
|
--dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset));
|
||||||
|
--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)));
|
||||||
--bg: oklch(0.15 0.01 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: oklch(0.19 0.012 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)));
|
||||||
--surface-subtle: oklch(0.23 0.012 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));
|
||||||
--fg: oklch(0.89 0.015 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: oklch(0.72 0.02 var(--dark-foreground-hue));
|
|
||||||
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
|
--muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border));
|
||||||
--sidebar-fg: oklch(0.78 0.02 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(0.33 0.01 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: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) 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-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));
|
--on-accent: oklch(0.15 0.015 var(--dark-foreground-hue));
|
||||||
|
|
||||||
--folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%);
|
--folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%);
|
||||||
|
|||||||
@@ -12,6 +12,48 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 1000000;
|
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 {
|
.panel-header {
|
||||||
@@ -489,6 +531,88 @@
|
|||||||
gap: 0.25rem;
|
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,
|
.detail-panel .doc-title-edit,
|
||||||
.document-summary .doc-title-edit {
|
.document-summary .doc-title-edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -773,7 +897,7 @@
|
|||||||
|
|
||||||
.detail-panel dt {
|
.detail-panel dt {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 0.8rem;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel dd {
|
.detail-panel dd {
|
||||||
@@ -786,7 +910,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 1rem 0;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
z-index: 2000000;
|
z-index: 950000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-floating__label {
|
.panel-floating__label {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 1rem 1rem;
|
padding: 0.5rem 1rem 0.5rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 2 2 auto;
|
flex: 1;
|
||||||
max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
|
min-height: 0;
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked .document-viewer__details-pane {
|
.document-viewer--stacked .document-viewer__details-pane {
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
order: 0;
|
order: 0;
|
||||||
}
|
}
|
||||||
@@ -191,6 +190,15 @@
|
|||||||
min-height: 0;
|
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 {
|
.document-viewer__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -270,28 +278,37 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tabs {
|
.document-viewer__tabs {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
border-bottom: 1px solid var(--outline-subtle);
|
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 {
|
.document-viewer__tab {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.35rem 0.9rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition:
|
transition:
|
||||||
color 120ms ease,
|
color 120ms ease,
|
||||||
border-color 120ms ease;
|
background-color 120ms ease;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tab:hover,
|
.document-viewer__tab:hover,
|
||||||
@@ -300,8 +317,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tab.is-active {
|
.document-viewer__tab.is-active {
|
||||||
color: var(--accent);
|
color: var(--accent-strong, var(--accent));
|
||||||
border-color: var(--accent);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanes {
|
.document-viewer__tabpanes {
|
||||||
@@ -310,6 +327,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-viewer--stacked .document-viewer__tabpanes {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.document-viewer__tabpanes--single {
|
.document-viewer__tabpanes--single {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -347,7 +368,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
grid-area: viewport;
|
grid-area: viewport;
|
||||||
@@ -371,12 +392,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
|
.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) {
|
||||||
height: calc(var(--document-viewer-portrait-height-ratio) * 100vh);
|
height: 100%;
|
||||||
max-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer--stacked .document-viewer__object--image {
|
.document-viewer--stacked .document-viewer__object--image {
|
||||||
height: auto;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-viewer__unsupported {
|
.document-viewer__unsupported {
|
||||||
@@ -410,3 +430,6 @@
|
|||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
.document-viewer__summary-tab-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-main--sidebar-collapsed {
|
.documents-main--sidebar-hidden {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
box-shadow: -12px 0 24px -12px var(--shadow-faint);
|
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);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.documents-main--sidebar-collapsed {
|
.documents-main--sidebar-hidden {
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,10 +128,53 @@
|
|||||||
color: var(--sidebar-fg);
|
color: var(--sidebar-fg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 20em;
|
width: min(100vw, var(--sidebar-width));
|
||||||
max-width: 20em;
|
max-width: min(100vw, var(--sidebar-width));
|
||||||
flex: 0 0 20em;
|
flex: 0 0 var(--sidebar-width);
|
||||||
background: var(--bg);
|
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 {
|
.sidebar__body {
|
||||||
|
|||||||
@@ -6,12 +6,17 @@
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
transform-origin: center center;
|
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;
|
outline: none;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
|
filter: blur(0px) grayscale(0%);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item__body {
|
.desk-item__body {
|
||||||
@@ -41,10 +46,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desk-item.is-filtered-out {
|
.desk-item.is-filtered-out {
|
||||||
opacity: 0.12;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
filter: blur(15px) grayscale(100%);
|
filter: blur(18px) grayscale(100%);
|
||||||
transition: opacity 0.6s ease, filter 0.28s ease;
|
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user