typescript
This commit is contained in:
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
|
||||
import { useDocumentsPreferences } from './useDocumentsPreferences';
|
||||
import SettingsRoute from './SettingsRoute';
|
||||
|
||||
const AppLayout = () => {
|
||||
const AppLayout: React.FC = () => {
|
||||
const documentsPreferences = useDocumentsPreferences();
|
||||
const {
|
||||
appStatus,
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { usePanelManager } from './PanelManagerContext';
|
||||
|
||||
const DocumentsLayout = ({ sidebarProps, children }) => {
|
||||
interface DocumentsLayoutProps {
|
||||
sidebarProps?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DocumentsLayout: React.FC<DocumentsLayoutProps> = ({ sidebarProps = {}, children }) => {
|
||||
const { collapsed } = useSidebarContext();
|
||||
const { sidebarSuppressed } = usePanelManager();
|
||||
const sidebarHidden = collapsed || sidebarSuppressed;
|
||||
@@ -8,7 +8,9 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
||||
|
||||
const DocumentsRouteContent = () => {
|
||||
type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
|
||||
|
||||
const DocumentsRouteContent: React.FC = () => {
|
||||
const {
|
||||
sidebarProps,
|
||||
documentsTableProps,
|
||||
@@ -64,7 +66,7 @@ const DocumentsRouteContent = () => {
|
||||
navigate(target);
|
||||
}, [navigate, parentBreadcrumb]);
|
||||
|
||||
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
|
||||
const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => {
|
||||
if (!crumb || !crumb.id) {
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +123,7 @@ const DocumentsRouteContent = () => {
|
||||
|
||||
const header = surface.header || null;
|
||||
|
||||
let headerTitle = null;
|
||||
let headerTitle: React.ReactNode = null;
|
||||
if (header) {
|
||||
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
|
||||
const lastIndex = breadcrumbEntries.length - 1;
|
||||
@@ -176,7 +178,7 @@ const DocumentsRouteContent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsRoute = () => (
|
||||
const DocumentsRoute: React.FC = () => (
|
||||
<SidebarProvider>
|
||||
<PanelManagerProvider>
|
||||
<DocumentsRouteContent />
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const DropOverlay = ({ active, folderName }) => (
|
||||
interface DropOverlayProps {
|
||||
active?: boolean;
|
||||
folderName?: string | null;
|
||||
}
|
||||
|
||||
const DropOverlay: React.FC<DropOverlayProps> = ({ active = false, folderName }) => (
|
||||
<div className={`drop-overlay${active ? ' active' : ''}`}>
|
||||
<div className="drop-overlay__content">
|
||||
Drop files to upload to <strong>{folderName || 'this location'}</strong>
|
||||
@@ -11,11 +11,31 @@ import {
|
||||
} from '../utils/webauthn';
|
||||
import { api, useAppDispatch, useAppState } from './appState';
|
||||
|
||||
const LoginRoute = () => {
|
||||
const { status: appStatus, tenantSelection } = useAppState();
|
||||
type StatusVariant = 'info' | 'success' | 'error';
|
||||
|
||||
interface StatusMessage {
|
||||
message: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface TenantSelectionState {
|
||||
selectionToken?: string | null;
|
||||
tenants?: TenantOption[];
|
||||
}
|
||||
|
||||
|
||||
const LoginRoute: React.FC = () => {
|
||||
const appState = useAppState();
|
||||
const { status: appStatus } = appState;
|
||||
const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null;
|
||||
const appDispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||
const passkeySupported = isWebAuthnAvailable();
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
+144
-57
@@ -1,4 +1,5 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -7,48 +8,85 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
|
||||
const PanelManagerContext = createContext(null);
|
||||
type PanelKey = 'sidebar' | 'detail';
|
||||
|
||||
const PANEL_LIMITS = {
|
||||
interface PanelLimits {
|
||||
maxRatio: number;
|
||||
minPx: number;
|
||||
}
|
||||
|
||||
interface SetPanelWidthOptions {
|
||||
commit?: boolean;
|
||||
log?: boolean;
|
||||
}
|
||||
|
||||
interface PanelManagerContextValue {
|
||||
sidebarWidth: number;
|
||||
detailWidth: number;
|
||||
sidebarSuppressed: boolean;
|
||||
resizingPanel: PanelKey | null;
|
||||
setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number;
|
||||
startPanelResize: (panel: PanelKey) => void;
|
||||
stopPanelResize: () => void;
|
||||
getPanelWidth: (panel: PanelKey) => number;
|
||||
setDetailActive: (isOpen: boolean) => void;
|
||||
closeDetailPanel: () => void;
|
||||
collapseSidebar: () => void;
|
||||
expandSidebar: () => void;
|
||||
registerDetailCloseHandler: (handler?: (() => void) | null) => void;
|
||||
detailPanelOpen: boolean;
|
||||
}
|
||||
|
||||
type PanelResizeBindings = {
|
||||
panelStyle?: CSSProperties;
|
||||
handleProps: {
|
||||
onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isPanelResizing: boolean;
|
||||
};
|
||||
|
||||
const PanelManagerContext = createContext<PanelManagerContextValue | null>(null);
|
||||
|
||||
const PANEL_LIMITS: Record<PanelKey, PanelLimits> = {
|
||||
sidebar: {
|
||||
minRatio: 1 / 6,
|
||||
maxRatio: 1 / 4,
|
||||
minPx: 240,
|
||||
maxRatio: 1 / 3,
|
||||
minPx: 280,
|
||||
},
|
||||
detail: {
|
||||
minRatio: 1 / 4,
|
||||
maxRatio: 3 / 4,
|
||||
maxRatio: 2 / 3,
|
||||
minPx: 320,
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
const STORAGE_KEYS: Record<PanelKey, string> = {
|
||||
sidebar: 'papercrate_sidebar_width',
|
||||
detail: 'papercrate_detail_width',
|
||||
};
|
||||
|
||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
const DEFAULT_DETAIL_WIDTH = 420;
|
||||
|
||||
const clampPanelWidth = (panel, value) => {
|
||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
|
||||
const MINIMUM_MAIN_CONTENT_WIDTH = 160;
|
||||
|
||||
const clampPanelWidth = (panel: PanelKey, value: number): number => {
|
||||
const numeric = Number(value);
|
||||
const limits = PANEL_LIMITS[panel];
|
||||
if (!limits) {
|
||||
return numeric;
|
||||
}
|
||||
const viewport = window.innerWidth;
|
||||
const ratioMin = Math.round(viewport * limits.minRatio);
|
||||
const minLimit = Math.max(0, limits.minPx);
|
||||
const ratioMax = Math.round(viewport * limits.maxRatio);
|
||||
const minLimit = Math.max(0, Math.max(ratioMin, limits.minPx));
|
||||
const rawMax = Math.max(minLimit, ratioMax);
|
||||
const maxAllowed = Math.min(rawMax, viewport - 160);
|
||||
const targetMax = Math.max(minLimit, maxAllowed);
|
||||
return Math.min(Math.max(numeric, minLimit), targetMax);
|
||||
const rawMax = Math.max(ratioMax, minLimit);
|
||||
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
|
||||
const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
|
||||
return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
|
||||
};
|
||||
|
||||
const readStoredWidth = (panel, fallback) => {
|
||||
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
|
||||
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -56,22 +94,26 @@ const readStoredWidth = (panel, fallback) => {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const persistWidth = (panel, value) => {
|
||||
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
const persistWidth = (panel: PanelKey, value: number): void => {
|
||||
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
};
|
||||
|
||||
const applyPanelWidthToRoot = (panel, width) => {
|
||||
const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => {
|
||||
if (!Number.isFinite(width)) {
|
||||
return;
|
||||
}
|
||||
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
||||
document?.documentElement?.style.setProperty(varName, `${width}px`);
|
||||
document.documentElement.style.setProperty(varName, `${width}px`);
|
||||
};
|
||||
|
||||
export const PanelManagerProvider = ({ children }) => {
|
||||
interface PanelManagerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children }) => {
|
||||
const { collapsed, setCollapsed } = useSidebarContext();
|
||||
const initialSidebarWidth = readStoredWidth('sidebar', 320);
|
||||
const initialDetailWidth = readStoredWidth('detail', 420);
|
||||
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
|
||||
|
||||
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
|
||||
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
|
||||
@@ -81,6 +123,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
|
||||
const detailCloseHandlerRef = useRef(null);
|
||||
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||
|
||||
const closeDetailPanel = useCallback(() => {
|
||||
const handler = detailCloseHandlerRef.current;
|
||||
@@ -97,15 +140,12 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
applyPanelWidthToRoot('detail', detailWidth);
|
||||
}, [detailWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
persistWidth('sidebar', sidebarWidth);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
persistWidth('detail', detailWidth);
|
||||
}, [detailWidth]);
|
||||
|
||||
const logPanelState = useCallback((panel, action, value) => {
|
||||
const handlePanelLayoutChange = useCallback((
|
||||
panel: PanelKey,
|
||||
action: 'opened' | 'closed' | 'resized',
|
||||
value?: number,
|
||||
{ detailOpen }: { detailOpen?: boolean } = {},
|
||||
) => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const sidebarWidth = panelWidthsRef.current.sidebar;
|
||||
const detailWidth = panelWidthsRef.current.detail;
|
||||
@@ -113,6 +153,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
|
||||
|
||||
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
|
||||
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
|
||||
|
||||
if (panel === 'detail' && !collapsed) {
|
||||
if (action === 'opened' || action === 'resized') {
|
||||
@@ -122,7 +163,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) {
|
||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) {
|
||||
closeDetailPanel();
|
||||
}
|
||||
|
||||
@@ -138,18 +179,17 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
minimalFreeRatio: MINIMAL_FREE_RATIO,
|
||||
meetsThreshold,
|
||||
});
|
||||
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]);
|
||||
}, [collapsed, closeDetailPanel, detailPanelOpen]);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
if (!collapsed) {
|
||||
setCollapsed(true);
|
||||
setSidebarSuppressed(false);
|
||||
logPanelState('sidebar', 'closed');
|
||||
handlePanelLayoutChange('sidebar', 'closed');
|
||||
}
|
||||
}, [collapsed, setCollapsed, logPanelState]);
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const setPanelWidth = useCallback(
|
||||
(panel, width, commit = true) => {
|
||||
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
|
||||
const clamped = clampPanelWidth(panel, width);
|
||||
if (!Number.isFinite(clamped)) {
|
||||
return panelWidthsRef.current[panel];
|
||||
@@ -162,34 +202,78 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
panelWidthsRef.current[panel] = clamped;
|
||||
applyPanelWidthToRoot(panel, clamped);
|
||||
if (commit) {
|
||||
preferredPanelWidthsRef.current[panel] = clamped;
|
||||
persistWidth(panel, clamped);
|
||||
}
|
||||
|
||||
logPanelState(panel, 'resized', clamped);
|
||||
if (log) {
|
||||
handlePanelLayoutChange(panel, 'resized', clamped);
|
||||
}
|
||||
return clamped;
|
||||
},
|
||||
[collapsed, logPanelState, sidebarSuppressed],
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const registerDetailCloseHandler = useCallback((handler = null) => {
|
||||
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
|
||||
const resetSidebarPreferredWidth = useCallback(() => {
|
||||
if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) {
|
||||
return;
|
||||
}
|
||||
preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||
persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
}, []);
|
||||
|
||||
const clampPanelsWithinViewport = useCallback(() => {
|
||||
const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
|
||||
if (viewportWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar;
|
||||
const desiredDetailWidth = preferredPanelWidthsRef.current.detail;
|
||||
|
||||
let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth);
|
||||
let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth);
|
||||
let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth);
|
||||
if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = 0;
|
||||
detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth);
|
||||
} else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = viewportWidth;
|
||||
detailDisplayWidth = 0;
|
||||
resetSidebarPreferredWidth();
|
||||
}
|
||||
|
||||
panelWidthsRef.current.sidebar = sidebarDisplayWidth;
|
||||
panelWidthsRef.current.detail = detailDisplayWidth;
|
||||
|
||||
setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth));
|
||||
setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth));
|
||||
}, [detailPanelOpen, resetSidebarPreferredWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
clampPanelsWithinViewport();
|
||||
window.addEventListener('resize', clampPanelsWithinViewport);
|
||||
return () => window.removeEventListener('resize', clampPanelsWithinViewport);
|
||||
}, [clampPanelsWithinViewport]);
|
||||
|
||||
const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => {
|
||||
detailCloseHandlerRef.current = handler ?? null;
|
||||
}, []);
|
||||
|
||||
const setDetailActive = useCallback(
|
||||
(isOpen) => {
|
||||
setDetailPanelOpen(Boolean(isOpen));
|
||||
logPanelState('detail', isOpen ? 'opened' : 'closed');
|
||||
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
|
||||
},
|
||||
[logPanelState],
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
setSidebarSuppressed(false);
|
||||
logPanelState('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, logPanelState]);
|
||||
handlePanelLayoutChange('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const startPanelResize = useCallback((panel) => {
|
||||
setResizingPanel(panel);
|
||||
@@ -247,7 +331,10 @@ export const usePanelManager = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => {
|
||||
export const usePanelResizeBindings = (
|
||||
panel: PanelKey,
|
||||
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
|
||||
): PanelResizeBindings => {
|
||||
const {
|
||||
sidebarWidth,
|
||||
detailWidth,
|
||||
@@ -277,7 +364,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
||||
useEffect(() => () => teardownListeners(), [teardownListeners]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event) => {
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !panelRef?.current) {
|
||||
return;
|
||||
}
|
||||
@@ -294,18 +381,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
||||
event.currentTarget?.setPointerCapture?.(pointerId);
|
||||
let lastWidth = startWidth;
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const delta = panel === 'sidebar'
|
||||
? moveEvent.clientX - startX
|
||||
: startX - moveEvent.clientX;
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, false);
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
|
||||
latestWidthRef.current = lastWidth;
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
|
||||
import useCapabilities from '../settings/useCapabilities';
|
||||
import { api } from './appState';
|
||||
|
||||
const SettingsRoute = ({ open = true, onClose }) => {
|
||||
interface SettingsRouteProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
|
||||
const {
|
||||
token,
|
||||
notifyApiError,
|
||||
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = useAppShell();
|
||||
} = useAppShell() as Record<string, any>;
|
||||
|
||||
const {
|
||||
tokens,
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CloseIcon,
|
||||
@@ -12,7 +13,23 @@ import {
|
||||
} from '../ui/icons';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
|
||||
const STATUS_META = {
|
||||
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
status: UploadStatus;
|
||||
error?: string | null;
|
||||
document?: { id?: string | number; title?: string };
|
||||
conflictDocumentId?: string | number;
|
||||
}
|
||||
|
||||
interface UploadQueueOverlayProps {
|
||||
queue?: UploadQueueItem[];
|
||||
onClearQueue?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
|
||||
pending: {
|
||||
label: 'Queued',
|
||||
tone: 'muted',
|
||||
@@ -40,7 +57,7 @@ const STATUS_META = {
|
||||
},
|
||||
};
|
||||
|
||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
|
||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
@@ -1,10 +1,52 @@
|
||||
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
const storage = window.sessionStorage;
|
||||
type Tenant = Record<string, unknown> | null;
|
||||
|
||||
interface TenantSelection {
|
||||
selectionToken: string;
|
||||
tenants: Tenant[];
|
||||
}
|
||||
|
||||
type AppStatus =
|
||||
| 'logged-out'
|
||||
| 'authenticating'
|
||||
| 'authenticated'
|
||||
| 'selecting-tenant'
|
||||
| 'bootstrapping'
|
||||
| 'ready';
|
||||
|
||||
interface AppState {
|
||||
status: AppStatus;
|
||||
token: string;
|
||||
error: string | null;
|
||||
isRefreshing: boolean;
|
||||
tenantSelection: TenantSelection | null;
|
||||
tenant: Tenant;
|
||||
tenants: Tenant[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: 'LOGIN_REQUEST' }
|
||||
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
|
||||
| { type: 'LOGIN_FAILURE'; error?: string | null }
|
||||
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
|
||||
| { type: 'CLEAR_TENANT_SELECTION' }
|
||||
| { type: 'LOGOUT_SUCCESS' }
|
||||
| { type: 'BOOTSTRAP_START' }
|
||||
| { type: 'BOOTSTRAP_SUCCESS' }
|
||||
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
|
||||
| { type: 'TOKEN_REFRESH_START' }
|
||||
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
|
||||
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
|
||||
| { type: 'LOGOUT' }
|
||||
| { type: 'RESET_ERROR' }
|
||||
| { type: 'SET_TENANTS'; tenants: Tenant[] };
|
||||
|
||||
const storage = typeof window !== 'undefined' ? window.sessionStorage : null;
|
||||
|
||||
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
|
||||
let STORED_TENANT = null;
|
||||
let STORED_TENANT: Tenant = null;
|
||||
|
||||
if (storage) {
|
||||
try {
|
||||
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||
}
|
||||
|
||||
const initialAppState = {
|
||||
const initialAppState: AppState = {
|
||||
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
|
||||
token: STORED_TOKEN,
|
||||
error: null,
|
||||
@@ -31,10 +73,10 @@ const initialAppState = {
|
||||
tenants: [],
|
||||
};
|
||||
|
||||
const AppStateContext = React.createContext(null);
|
||||
const AppDispatchContext = React.createContext(null);
|
||||
const AppStateContext = React.createContext<AppState | null>(null);
|
||||
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
|
||||
|
||||
const appStateReducer = (state, action) => {
|
||||
const appStateReducer = (state: AppState, action: AppAction): AppState => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN_REQUEST':
|
||||
return {
|
||||
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
|
||||
token: action.token,
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || null,
|
||||
tenant: action.tenant ?? null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
error: action.error ?? null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGOUT_SUCCESS':
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
|
||||
case 'BOOTSTRAP_SUCCESS':
|
||||
return { ...state, status: 'ready', error: null };
|
||||
case 'BOOTSTRAP_FAILURE':
|
||||
return { ...state, status: 'authenticated', error: action.error || null };
|
||||
return { ...state, status: 'authenticated', error: action.error ?? null };
|
||||
case 'TOKEN_REFRESH_START':
|
||||
return { ...state, isRefreshing: true, error: null };
|
||||
case 'TOKEN_REFRESH_SUCCESS':
|
||||
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
|
||||
isRefreshing: false,
|
||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || state.tenant || null,
|
||||
tenant: action.tenant ?? state.tenant ?? null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'TOKEN_REFRESH_FAILURE':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: null,
|
||||
error: action.error ?? null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
@@ -148,7 +181,7 @@ const appStateReducer = (state, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
const AppStateProvider = ({ children }) => {
|
||||
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useAppState = () => {
|
||||
const useAppState = (): AppState => {
|
||||
const context = useContext(AppStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppState must be used within an AppStateProvider.');
|
||||
@@ -224,7 +257,7 @@ const useAppState = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
const useAppDispatch = () => {
|
||||
const useAppDispatch = (): React.Dispatch<AppAction> => {
|
||||
const context = useContext(AppDispatchContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppDispatch must be used within an AppStateProvider.');
|
||||
@@ -1,13 +1,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface DetailDocument {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDetailPanelOptions {
|
||||
documentLookup: Map<string | number, DetailDocument>;
|
||||
orderedSelectedDocuments: DetailDocument[];
|
||||
}
|
||||
|
||||
interface OpenDetailPanelArgs {
|
||||
documentId?: string | number;
|
||||
document?: DetailDocument | null;
|
||||
documentIds?: Array<string | number>;
|
||||
documents?: DetailDocument[];
|
||||
}
|
||||
|
||||
export const useDetailPanel = ({
|
||||
documentLookup,
|
||||
orderedSelectedDocuments,
|
||||
}) => {
|
||||
}: UseDetailPanelOptions) => {
|
||||
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
|
||||
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
|
||||
const latestOrderedDocsRef = useRef([]);
|
||||
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
|
||||
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
|
||||
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
|
||||
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
||||
|
||||
const openDetailPanel = useCallback(
|
||||
({ documentId, document, documentIds, documents } = {}) => {
|
||||
({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
|
||||
let targetDoc = document || null;
|
||||
let targetId = documentId ?? document?.id ?? null;
|
||||
|
||||
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailPanelDocId(targetDoc?.id || targetId || null);
|
||||
setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
|
||||
setDetailPanelDocument(targetDoc || null);
|
||||
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
||||
},
|
||||
@@ -1,4 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
type DocumentId = string | number;
|
||||
type FolderId = DocumentId | 'root';
|
||||
|
||||
type DocumentLike = {
|
||||
id?: DocumentId;
|
||||
folder_id?: FolderId | null;
|
||||
filename?: string | null;
|
||||
current_version?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PreviewEntry = {
|
||||
url?: string;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
expiresAt?: number;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
|
||||
interface AssetManagerLike {
|
||||
hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null | undefined;
|
||||
hydrateDocument: (payload: unknown) => DocumentLike | null | undefined;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get: <T = unknown>(path: string) => Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
|
||||
|
||||
interface UseDocumentPreviewArgs {
|
||||
routeDocumentId?: DocumentId | null;
|
||||
documents: DocumentLike[];
|
||||
searchResults: DocumentLike[] | null;
|
||||
selectedFolder?: FolderId | null;
|
||||
assetManager: AssetManagerLike;
|
||||
api: ApiClient;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
navigate: NavigateHandler;
|
||||
locationPathname: string;
|
||||
locationSearch: string;
|
||||
detailPanelControlRef: MutableRefObject<{
|
||||
open?: (args?: { documentIds?: DocumentId[] }) => void;
|
||||
close?: () => void;
|
||||
} | null>;
|
||||
setActivePreviewId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||
}
|
||||
|
||||
interface UseDocumentPreviewResult {
|
||||
previewEntries: Map<DocumentId, PreviewEntry>;
|
||||
previewDocuments: Map<DocumentId, DocumentLike>;
|
||||
ensurePreviewUrl: (documentId: DocumentId | null, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
|
||||
ensurePreviewData: (documentId: DocumentId | null) => Promise<DocumentLike | null>;
|
||||
openDocumentPreview: (documentId: DocumentId | null, options?: { replace?: boolean }) => void;
|
||||
closeDocumentPreview: (folderId?: FolderId | null) => void;
|
||||
resetPreviewState: () => void;
|
||||
removePreviewEntries: (ids: DocumentId[]) => void;
|
||||
}
|
||||
|
||||
const useDocumentPreview = ({
|
||||
routeDocumentId,
|
||||
@@ -14,11 +82,11 @@ const useDocumentPreview = ({
|
||||
locationSearch,
|
||||
detailPanelControlRef,
|
||||
setActivePreviewId,
|
||||
}) => {
|
||||
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
|
||||
const previewInflightRef = useRef(new Map());
|
||||
const previewReturnPathRef = useRef(null);
|
||||
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
|
||||
const [previewEntries, setPreviewEntries] = useState<Map<DocumentId, PreviewEntry>>(() => new Map());
|
||||
const [previewDocuments, setPreviewDocuments] = useState<Map<DocumentId, DocumentLike>>(() => new Map());
|
||||
const previewInflightRef = useRef<Map<DocumentId, Promise<PreviewEntry | null>>>(new Map());
|
||||
const previewReturnPathRef = useRef<string | null>(null);
|
||||
|
||||
const resetPreviewState = useCallback(() => {
|
||||
setPreviewEntries(() => new Map());
|
||||
@@ -26,7 +94,7 @@ const useDocumentPreview = ({
|
||||
previewReturnPathRef.current = null;
|
||||
}, []);
|
||||
|
||||
const removePreviewEntries = useCallback((ids) => {
|
||||
const removePreviewEntries = useCallback((ids: DocumentId[]) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -46,7 +114,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cachePreviewDocument = useCallback((doc) => {
|
||||
const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => {
|
||||
if (!doc?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +129,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeCachedPreviewDocument = useCallback((documentId) => {
|
||||
const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +144,7 @@ const useDocumentPreview = ({
|
||||
}, []);
|
||||
|
||||
const ensurePreviewUrl = useCallback(
|
||||
async (documentId, { force = false } = {}) => {
|
||||
async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const existing = previewEntries.get(documentId) || null;
|
||||
@@ -87,19 +155,19 @@ const useDocumentPreview = ({
|
||||
}
|
||||
|
||||
if (!force && previewInflightRef.current.has(documentId)) {
|
||||
return previewInflightRef.current.get(documentId);
|
||||
return previewInflightRef.current.get(documentId) || null;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const request: Promise<PreviewEntry | null> = (async () => {
|
||||
try {
|
||||
const docResponse = await api.get(`/documents/${documentId}`);
|
||||
const docResponse = await api.get<{ document?: Record<string, any> }>(`/documents/${documentId}`);
|
||||
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
||||
if (!downloadPath || !resolveApiPath) {
|
||||
throw new Error('Document missing download path');
|
||||
}
|
||||
|
||||
const href = resolveApiPath(downloadPath);
|
||||
const entry = {
|
||||
const entry: PreviewEntry = {
|
||||
url: href,
|
||||
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
||||
filename: docResponse.data?.document?.filename,
|
||||
@@ -122,11 +190,11 @@ const useDocumentPreview = ({
|
||||
previewInflightRef.current.set(documentId, request);
|
||||
return request;
|
||||
},
|
||||
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
|
||||
[previewEntries, api, resolveApiPath, notifyApiError],
|
||||
);
|
||||
|
||||
const ensurePreviewData = useCallback(
|
||||
async (documentId) => {
|
||||
async (documentId: DocumentId | null): Promise<DocumentLike | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const findInCache = () => {
|
||||
@@ -139,18 +207,18 @@ const useDocumentPreview = ({
|
||||
if (!doc) {
|
||||
const { data } = await api.get(`/documents/${documentId}`);
|
||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||
const fetched = hydratedDetail?.document || data.document || data;
|
||||
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
||||
const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data;
|
||||
doc = fetched ? assetManager.hydrateDocument(fetched) || null : null;
|
||||
if (!doc) {
|
||||
throw new Error('Document metadata unavailable.');
|
||||
}
|
||||
|
||||
const existsInDocuments = documents.some((item) => item.id === doc.id);
|
||||
const existsInDocuments = documents.some((item) => item.id === doc?.id);
|
||||
const existsInSearch = Array.isArray(searchResults)
|
||||
? searchResults.some((item) => item.id === doc.id)
|
||||
? searchResults.some((item) => item.id === doc?.id)
|
||||
: false;
|
||||
|
||||
if (existsInDocuments || existsInSearch) {
|
||||
if (doc?.id && (existsInDocuments || existsInSearch)) {
|
||||
removeCachedPreviewDocument(doc.id);
|
||||
} else {
|
||||
cachePreviewDocument(doc);
|
||||
@@ -180,9 +248,9 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const openDocumentPreview = useCallback(
|
||||
(documentId, { replace = false } = {}) => {
|
||||
(documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => {
|
||||
if (!documentId) return;
|
||||
detailPanelControlRef.current.close();
|
||||
detailPanelControlRef.current?.close?.();
|
||||
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||
navigate(`/documents/${documentId}`, { replace });
|
||||
},
|
||||
@@ -190,7 +258,7 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const closeDocumentPreview = useCallback(
|
||||
(folderId = null) => {
|
||||
(folderId: FolderId | null = null) => {
|
||||
const fallbackPath = previewReturnPathRef.current;
|
||||
previewReturnPathRef.current = null;
|
||||
|
||||
@@ -211,7 +279,7 @@ const useDocumentPreview = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
@@ -248,7 +316,7 @@ const useDocumentPreview = ({
|
||||
}
|
||||
const next = new Map(prev);
|
||||
let changed = false;
|
||||
const prune = (list) => {
|
||||
const prune = (list?: DocumentLike[] | null) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
+53
-23
@@ -1,6 +1,30 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_INITIAL_ENTRIES = [];
|
||||
type RowKey = string;
|
||||
type DocumentId = string | number;
|
||||
|
||||
interface SelectionEventLike {
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
preventDefault?: () => void;
|
||||
}
|
||||
|
||||
interface UseDocumentSelectionOptions {
|
||||
resolveDocumentRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
resolveFolderRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
isDocumentRowKey: (key?: RowKey | null) => boolean;
|
||||
isFolderRowKey: (key?: RowKey | null) => boolean;
|
||||
getRowId: (key?: RowKey | null) => DocumentId | null | undefined;
|
||||
initialEntries?: RowKey[];
|
||||
}
|
||||
|
||||
interface ApplySelectionOptions {
|
||||
anchor?: RowKey | null;
|
||||
interactedKeys?: RowKey[];
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_ENTRIES: RowKey[] = [];
|
||||
|
||||
export const useDocumentSelection = ({
|
||||
resolveDocumentRowKey,
|
||||
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
|
||||
isFolderRowKey,
|
||||
getRowId,
|
||||
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
||||
}) => {
|
||||
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
|
||||
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
|
||||
const selectionOrderRef = useRef(initialEntries);
|
||||
const selectionAnchorRef = useRef(null);
|
||||
}: UseDocumentSelectionOptions) => {
|
||||
const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
|
||||
const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
|
||||
const selectionOrderRef = useRef<RowKey[]>(initialEntries);
|
||||
const selectionAnchorRef = useRef<RowKey | null>(null);
|
||||
const selectionInitializedRef = useRef(false);
|
||||
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
|
||||
const [focusedRowKey, setFocusedRowKey] = useState(null);
|
||||
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
|
||||
const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
|
||||
|
||||
const visibleRowKeySetRef = useRef(new Set());
|
||||
const navigableRowKeysRef = useRef([]);
|
||||
const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
|
||||
const navigableRowKeysRef = useRef<RowKey[]>([]);
|
||||
|
||||
const configureSelectionEnvironment = useCallback(({
|
||||
visibleRowKeySet,
|
||||
navigableRowKeys,
|
||||
}: {
|
||||
visibleRowKeySet?: Set<RowKey>;
|
||||
navigableRowKeys?: RowKey[];
|
||||
}) => {
|
||||
if (visibleRowKeySet) {
|
||||
visibleRowKeySetRef.current = visibleRowKeySet;
|
||||
@@ -33,7 +60,7 @@ export const useDocumentSelection = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
||||
const updateSelectionOrder = useCallback((nextSelection: RowKey[], interactedKeys: RowKey[] = []) => {
|
||||
const nextSet = new Set(nextSelection);
|
||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
|
||||
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
|
||||
}, []);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
||||
(
|
||||
rowKeys: Array<RowKey | null | undefined>,
|
||||
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
|
||||
) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
const unique = [];
|
||||
const unique: RowKey[] = [];
|
||||
|
||||
(rowKeys || []).forEach((key) => {
|
||||
(rowKeys || []).forEach((key) => {
|
||||
if (!key) return;
|
||||
let canonicalKey = null;
|
||||
let canonicalKey: RowKey | null | undefined = null;
|
||||
if (visibleRowKeySet.has(key)) {
|
||||
canonicalKey = key;
|
||||
} else if (isDocumentRowKey(key)) {
|
||||
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
|
||||
setSelectedEntries(unique);
|
||||
updateSelectionOrder(unique, interactedKeys);
|
||||
|
||||
const nextFocusedDocumentId = (() => {
|
||||
const nextFocusedDocumentId: DocumentId | null = (() => {
|
||||
if (focusedDocumentId) {
|
||||
const focusKey = resolveDocumentRowKey(focusedDocumentId);
|
||||
if (focusKey && unique.includes(focusKey)) {
|
||||
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
|
||||
}
|
||||
|
||||
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
|
||||
return getRowId(resolvedAnchor) || null;
|
||||
return getRowId(resolvedAnchor) ?? null;
|
||||
}
|
||||
|
||||
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
|
||||
return lastDocKey ? getRowId(lastDocKey) || null : null;
|
||||
const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
|
||||
return lastDocKey ? getRowId(lastDocKey) ?? null : null;
|
||||
})();
|
||||
|
||||
setFocusedDocumentId(nextFocusedDocumentId);
|
||||
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
|
||||
}, [applySelection]);
|
||||
|
||||
const handleEntrySelection = useCallback(
|
||||
(rowKey, event) => {
|
||||
(rowKey: RowKey | null | undefined, event?: SelectionEventLike) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
const navigableRowKeys = navigableRowKeysRef.current;
|
||||
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
||||
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
|
||||
anchorKey = rowKey;
|
||||
}
|
||||
|
||||
let nextKeys = [];
|
||||
let interactedKeys = [];
|
||||
let nextKeys: RowKey[] = [];
|
||||
let interactedKeys: RowKey[] = [];
|
||||
|
||||
if (shiftKey && anchorKey) {
|
||||
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
|
||||
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
|
||||
);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId) => {
|
||||
(docId?: DocumentId | null) => {
|
||||
if (!docId) return;
|
||||
const rowKey = resolveDocumentRowKey(docId);
|
||||
if (!rowKey) return;
|
||||
+6
-6
@@ -10,7 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
||||
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||
|
||||
const readSessionStorage = (key) => {
|
||||
const readSessionStorage = (key: string): string | null => {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key);
|
||||
} catch (error) {
|
||||
@@ -19,7 +19,7 @@ const readSessionStorage = (key) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeSessionStorage = (key, value) => {
|
||||
const writeSessionStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
@@ -28,7 +28,7 @@ const writeSessionStorage = (key, value) => {
|
||||
};
|
||||
|
||||
export const useDocumentsPreferences = () => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
|
||||
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||
if (stored === 'grid' || stored === 'desk') {
|
||||
return stored;
|
||||
@@ -36,9 +36,9 @@ export const useDocumentsPreferences = () => {
|
||||
return 'list';
|
||||
});
|
||||
|
||||
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
||||
const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid');
|
||||
|
||||
const setDocumentsViewMode = useCallback((mode) => {
|
||||
const setDocumentsViewMode = useCallback((mode: string) => {
|
||||
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||
setDocumentsViewModeState((previous) => {
|
||||
if (next !== previous) {
|
||||
@@ -75,7 +75,7 @@ export const useDocumentsPreferences = () => {
|
||||
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||
}, [documentsSortDirection]);
|
||||
|
||||
const handleDocumentsSortFieldChange = useCallback((field) => {
|
||||
const handleDocumentsSortFieldChange = useCallback((field: string) => {
|
||||
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||
}, []);
|
||||
@@ -1,10 +1,77 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
TAG_FILTER_UNTAGGED,
|
||||
resolveDocumentRowKey,
|
||||
isDocumentRowKey,
|
||||
} from './appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type DocumentLike = { id?: Identifier } & Record<string, unknown>;
|
||||
|
||||
type ApiClient = {
|
||||
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
|
||||
};
|
||||
|
||||
type AssetManagerLike = {
|
||||
hydrateDocuments: (payload: unknown[]) => DocumentLike[];
|
||||
};
|
||||
|
||||
type SelectionHelpers = {
|
||||
setSelectedEntries: (
|
||||
updater:
|
||||
| Array<Identifier | string>
|
||||
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
|
||||
) => void;
|
||||
setSelectionOrder: (
|
||||
updater:
|
||||
| Array<Identifier | string>
|
||||
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
|
||||
) => void;
|
||||
selectionOrderRef: { current: Array<Identifier | string> };
|
||||
selectionAnchorRef: { current: Identifier | string | null };
|
||||
setFocusedDocumentId: (
|
||||
updater: Identifier | null | ((prev: Identifier | null) => Identifier | null),
|
||||
) => void;
|
||||
};
|
||||
|
||||
interface UseDocumentsSearchArgs {
|
||||
api: ApiClient;
|
||||
assetManager: AssetManagerLike;
|
||||
token?: string | null;
|
||||
selectedFolder?: Identifier | 'root' | null;
|
||||
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
||||
locationPathname?: string;
|
||||
isDocumentsRoute?: boolean;
|
||||
selectionHelpers: SelectionHelpers;
|
||||
searchIncludeDescendants?: boolean;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
setSearchIncludeDescendants: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseDocumentsSearchResult {
|
||||
searchQuery: string;
|
||||
setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||
searchResults: DocumentLike[] | null;
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
|
||||
searchLoading: boolean;
|
||||
setSearchLoading: Dispatch<SetStateAction<boolean>>;
|
||||
activeTagFilters: Identifier[];
|
||||
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||
activeCorrespondentFilters: Identifier[];
|
||||
setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||
toggleTagFilter: (tagId: Identifier) => void;
|
||||
toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void;
|
||||
isFilterActive: boolean;
|
||||
clearFilters: () => void;
|
||||
handleSearchChange: (value: string) => void;
|
||||
handleSearchSubmit: () => void;
|
||||
}
|
||||
|
||||
const useDocumentsSearch = ({
|
||||
api,
|
||||
assetManager,
|
||||
@@ -20,14 +87,14 @@ const useDocumentsSearch = ({
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchIncludeDescendants,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<DocumentLike[] | null>(null);
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
|
||||
const toggleTagFilter = useCallback((tagId) => {
|
||||
const toggleTagFilter = useCallback((tagId: Identifier) => {
|
||||
if (!tagId) return;
|
||||
setActiveTagFilters((previous) => {
|
||||
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||
@@ -41,7 +108,7 @@ const useDocumentsSearch = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
|
||||
setActiveCorrespondentFilters((previous) => {
|
||||
if (!correspondentId) {
|
||||
return [];
|
||||
@@ -68,7 +135,7 @@ const useDocumentsSearch = ({
|
||||
setSearchIncludeDescendants,
|
||||
]);
|
||||
|
||||
const handleSearchChange = useCallback((value) => {
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
@@ -98,7 +165,7 @@ const useDocumentsSearch = ({
|
||||
started = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
if (trimmedQuery.length) {
|
||||
params.query = trimmedQuery;
|
||||
@@ -131,7 +198,7 @@ const useDocumentsSearch = ({
|
||||
if (documentsSortDirection) {
|
||||
params.dir = documentsSortDirection;
|
||||
}
|
||||
const { data } = await api.get('/documents', { params });
|
||||
const { data } = await api.get<unknown[]>('/documents', { params });
|
||||
if (cancelled) return;
|
||||
|
||||
const results = assetManager.hydrateDocuments(data || []);
|
||||
@@ -148,11 +215,11 @@ const useDocumentsSearch = ({
|
||||
}
|
||||
|
||||
const resultKeys = results
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.filter(Boolean);
|
||||
.map((doc) => resolveDocumentRowKey(doc.id as Identifier))
|
||||
.filter(Boolean) as Array<Identifier | string>;
|
||||
|
||||
let targetKey = null;
|
||||
let nextSelectionKeys = [];
|
||||
let targetKey: Identifier | string | null = null;
|
||||
let nextSelectionKeys: Array<Identifier | string> = [];
|
||||
|
||||
selectionHelpers.setSelectedEntries((previous) => {
|
||||
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||
@@ -171,7 +238,7 @@ const useDocumentsSearch = ({
|
||||
selectionHelpers.setSelectionOrder(nextSelectionKeys);
|
||||
|
||||
selectionHelpers.setFocusedDocumentId((previous) => {
|
||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous as Identifier))) {
|
||||
return previous;
|
||||
}
|
||||
return null;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import TagsPanel from '../tags/TagsPanel';
|
||||
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
@@ -6,21 +7,56 @@ import PanelHeader from '../ui/PanelHeader';
|
||||
const TAGS_MODAL = 'tags';
|
||||
const CORRESPONDENTS_MODAL = 'correspondents';
|
||||
|
||||
interface TagRecord {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CorrespondentRecord {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseManagementModalsArgs {
|
||||
locationPathname?: string;
|
||||
tags?: TagRecord[];
|
||||
refreshTags?: () => void | Promise<void>;
|
||||
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
||||
correspondents?: CorrespondentRecord[];
|
||||
refreshCorrespondents?: () => void | Promise<void>;
|
||||
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
||||
setStatusMessage?: (message: string, variant?: string) => void;
|
||||
}
|
||||
|
||||
interface UseManagementModalsResult {
|
||||
managementModals: ReactNode;
|
||||
openTagsModal: () => void;
|
||||
openCorrespondentsModal: () => void;
|
||||
closeActiveModal: () => void;
|
||||
activeModal: string | null;
|
||||
}
|
||||
|
||||
export const useManagementModals = ({
|
||||
locationPathname,
|
||||
tags,
|
||||
tags = [],
|
||||
refreshTags,
|
||||
onTagCreate,
|
||||
onTagUpdate,
|
||||
onTagDelete,
|
||||
correspondents,
|
||||
correspondents = [],
|
||||
refreshCorrespondents,
|
||||
onCorrespondentCreate,
|
||||
onCorrespondentUpdate,
|
||||
onCorrespondentDelete,
|
||||
setStatusMessage,
|
||||
}) => {
|
||||
const [activeModal, setActiveModal] = useState(null);
|
||||
}: UseManagementModalsArgs): UseManagementModalsResult => {
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
|
||||
const openCorrespondentsModal = useCallback(
|
||||
@@ -37,7 +73,7 @@ export const useManagementModals = ({
|
||||
if (!activeModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveModal(null);
|
||||
+28
-9
@@ -1,17 +1,34 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDocumentSelection } from './useDocumentSelection';
|
||||
|
||||
const identity = (value) => value;
|
||||
type RowKey = string;
|
||||
|
||||
interface SelectionEntry {
|
||||
rowKey?: RowKey;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface WorkspaceSelectionOptions {
|
||||
resolveDocumentRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
resolveFolderRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
getRowId?: (key: RowKey | SelectionEntry) => string | number | null;
|
||||
onInspectDocument?: (id: string | number) => void;
|
||||
onInspectFolder?: (id: string | number) => void;
|
||||
}
|
||||
|
||||
const identity = <T,>(value: T) => value;
|
||||
|
||||
export const useWorkspaceSelection = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
isDocumentRowKey,
|
||||
isFolderRowKey,
|
||||
getRowId,
|
||||
isDocumentRowKey = () => false,
|
||||
isFolderRowKey = () => false,
|
||||
getRowId = () => null,
|
||||
onInspectDocument = identity,
|
||||
onInspectFolder = identity,
|
||||
} = {}) => {
|
||||
}: WorkspaceSelectionOptions = {}) => {
|
||||
const selection = useDocumentSelection({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const selectEntry = useCallback(
|
||||
(entry, event) => {
|
||||
const rowKey = entry?.rowKey ?? (typeof entry?.split === 'function' ? entry : null);
|
||||
(entry: SelectionEntry | string | null, event?: unknown) => {
|
||||
const rowKey = typeof entry === 'string'
|
||||
? entry
|
||||
: entry?.rowKey ?? null;
|
||||
if (!rowKey) return;
|
||||
handleEntrySelection(rowKey, event);
|
||||
},
|
||||
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const inspectDocument = useCallback(
|
||||
(documentId) => {
|
||||
(documentId?: string | number | null) => {
|
||||
if (!documentId) return;
|
||||
onInspectDocument(documentId);
|
||||
},
|
||||
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const inspectFolder = useCallback(
|
||||
(folderId) => {
|
||||
(folderId?: string | number | null) => {
|
||||
if (!folderId) return;
|
||||
onInspectFolder(folderId);
|
||||
},
|
||||
+56
-10
@@ -1,17 +1,63 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarExpandIcon } from '../ui/icons';
|
||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||
import createDesktopSurface from '../desktop/createDesktopSurface';
|
||||
import { usePanelManager } from './PanelManagerContext';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record<string, unknown>) => Promise<unknown> | void;
|
||||
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
type DocumentsSurface = ReturnType<typeof createDocumentsSurface> | null;
|
||||
type PreviewSurface = ReturnType<typeof createDocumentViewerSurface> | null;
|
||||
type DesktopSurface = ReturnType<typeof createDesktopSurface> | null;
|
||||
type WorkspaceSurface = DocumentsSurface | PreviewSurface | DesktopSurface | null;
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceSurfaceArgs {
|
||||
sidebarHidden?: boolean;
|
||||
onExpandSidebar?: () => void;
|
||||
documentsTableProps?: Record<string, any> | null;
|
||||
detailPanelProps?: (Record<string, any> & { onClose?: () => void }) | null;
|
||||
detailPanelOpen?: boolean;
|
||||
viewMode?: string;
|
||||
deskWorkspaceProps?: Record<string, any> | null;
|
||||
previewWorkspaceDocument?: unknown;
|
||||
previewWorkspaceEntry?: unknown;
|
||||
previewDocumentId?: Identifier | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
ensurePreviewData?: EnsurePreviewData;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
resolveApiPath?: ResolveApiPath;
|
||||
notifyApiError?: NotifyApiError;
|
||||
closeDocumentPreview?: () => void;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
}
|
||||
|
||||
interface UseWorkspaceSurfaceResult {
|
||||
surface: WorkspaceSurface;
|
||||
}
|
||||
|
||||
export const useWorkspaceSurface = ({
|
||||
sidebarHidden,
|
||||
sidebarHidden = false,
|
||||
onExpandSidebar,
|
||||
documentsTableProps,
|
||||
detailPanelProps,
|
||||
detailPanelOpen,
|
||||
viewMode,
|
||||
detailPanelOpen = false,
|
||||
viewMode = 'list',
|
||||
deskWorkspaceProps,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
@@ -24,7 +70,7 @@ export const useWorkspaceSurface = ({
|
||||
closeDocumentPreview,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
}) => {
|
||||
}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
|
||||
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +84,7 @@ export const useWorkspaceSurface = ({
|
||||
return () => setDetailActive(false);
|
||||
}, [detailPanelOpen, setDetailActive]);
|
||||
|
||||
const renderSidebarToggle = useCallback(() => {
|
||||
const renderSidebarToggle = useCallback<() => ReactNode>(() => {
|
||||
if (!sidebarHidden) {
|
||||
return null;
|
||||
}
|
||||
@@ -55,7 +101,7 @@ export const useWorkspaceSurface = ({
|
||||
);
|
||||
}, [sidebarHidden, onExpandSidebar]);
|
||||
|
||||
const documentsSurface = useMemo(() => {
|
||||
const documentsSurface = useMemo<DocumentsSurface>(() => {
|
||||
if (!documentsTableProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,7 +124,7 @@ export const useWorkspaceSurface = ({
|
||||
|
||||
const showPreviewWorkspace = Boolean(previewDocumentId);
|
||||
|
||||
const previewSurface = useMemo(() => {
|
||||
const previewSurface = useMemo<PreviewSurface>(() => {
|
||||
if (!showPreviewWorkspace) {
|
||||
return null;
|
||||
}
|
||||
@@ -132,7 +178,7 @@ export const useWorkspaceSurface = ({
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const workspaceSurface = useMemo(() => {
|
||||
const workspaceSurface = useMemo<DesktopSurface>(() => {
|
||||
if (viewMode !== 'desk') {
|
||||
return null;
|
||||
}
|
||||
@@ -154,7 +200,7 @@ export const useWorkspaceSurface = ({
|
||||
detailPanelOpen,
|
||||
]);
|
||||
|
||||
const surface = useMemo(() => {
|
||||
const surface = useMemo<WorkspaceSurface>(() => {
|
||||
if (showPreviewWorkspace) {
|
||||
return previewSurface;
|
||||
}
|
||||
Reference in New Issue
Block a user