Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
/dist
|
||||
/.env.local
|
||||
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
ARG NODE_IMAGE=node:20-alpine
|
||||
ARG NGINX_IMAGE=nginx:alpine
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM ${NGINX_IMAGE}
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=build /app/dist ./
|
||||
|
||||
ENV API_PROXY_PASS=""
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Papercrate Frontend
|
||||
|
||||
A minimal Webpack-powered SPA to interact with the Papercrate Milestone 1 backend.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Backend API running locally on `http://127.0.0.1:3000`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Starts `webpack-dev-server` on <http://localhost:5173>
|
||||
- Proxies `/api` requests to the backend (no CORS needed)
|
||||
- Edit files in `src/` and the browser reloads automatically
|
||||
|
||||
## Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
- Output written to `dist/`
|
||||
- Set `API_BASE_URL` in `.env.local` if the API is not served from the same origin.
|
||||
|
||||
## Code Quality
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
- Runs TypeScript type checking (`tsc`) and ESLint.
|
||||
- Use this before committing changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Finder-style layout: folder tree, document table, and detail pane with metadata & tags
|
||||
- Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder)
|
||||
- Search box plus tag chips filter documents across the selected folder and all descendants
|
||||
- Tag management (create/assign/remove) from the detail panel
|
||||
- Login with a WebAuthn passkey created through the signup flow (no baked-in demo account)
|
||||
- Inline status banner for quick feedback on API interactions
|
||||
|
||||
## Assets
|
||||
|
||||
- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
|
||||
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: 'defaults',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@babel/preset-react',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
},
|
||||
],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
# Add mjs to mime.types if not present
|
||||
sed -i 's|application/javascript|application/javascript mjs|' /etc/nginx/mime.types
|
||||
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
|
||||
|
||||
MAX_BODY_SIZE_RAW="${UPLOAD_BODY_LIMIT_BYTES:-}"
|
||||
if [ -n "$MAX_BODY_SIZE_RAW" ]; then
|
||||
MAX_BODY_SIZE=$(printf '%sm' "$((MAX_BODY_SIZE_RAW / (1024 * 1024)))")
|
||||
else
|
||||
MAX_BODY_SIZE="128m"
|
||||
fi
|
||||
|
||||
cat <<BASE > /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files \$uri /index.html;
|
||||
}
|
||||
BASE
|
||||
|
||||
if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
|
||||
cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
client_max_body_size ${MAX_BODY_SIZE};
|
||||
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
PROXY
|
||||
fi
|
||||
|
||||
cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf
|
||||
}
|
||||
ENDCFG
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,86 @@
|
||||
import js from '@eslint/js';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsPluginImport from '@typescript-eslint/eslint-plugin';
|
||||
|
||||
const tsPlugin = tsPluginImport.default ?? tsPluginImport;
|
||||
|
||||
const sharedRules = {
|
||||
...js.configs.recommended.rules,
|
||||
...pluginReact.configs.recommended.rules,
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'no-use-before-define': [
|
||||
'error',
|
||||
{ functions: false, classes: true, variables: true },
|
||||
],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
};
|
||||
|
||||
const sharedLanguageOptions = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist', 'node_modules'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{js,jsx}', 'tests/**/*.{js,jsx}'],
|
||||
languageOptions: sharedLanguageOptions,
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'react-hooks': pluginReactHooks,
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: sharedRules,
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
...sharedLanguageOptions,
|
||||
parser: tsParser,
|
||||
},
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'react-hooks': pluginReactHooks,
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...sharedRules,
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
Generated
+11580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "papercrate-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Rudimentary Webpack SPA for Papercrate Milestone 1",
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"check": "tsc --noEmit && npx knip && npm run lint",
|
||||
"test:engine": "node --test tests/workspaceEngine.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"axios": "^1.13.2",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"babel-loader": "^10.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^16.5.0",
|
||||
"html-webpack-plugin": "^5.6.4",
|
||||
"knip": "^5.71.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DocumentsFilterProvider,
|
||||
} from '../documents/context/DocumentsFilterContext';
|
||||
import { FullscreenPreviewProvider, useFullscreenPreviewContext } from '../viewer/FullscreenPreviewContext';
|
||||
import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext';
|
||||
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import useDocumentsShell from './useDocumentsShell';
|
||||
|
||||
const DocumentsInner: React.FC<{
|
||||
surfaceConfig: any;
|
||||
onNavigate: (documentId: string) => void;
|
||||
}> = ({ surfaceConfig, onNavigate }) => {
|
||||
const { openFullscreenPreview } = useFullscreenPreviewContext();
|
||||
const { collapsed: sidebarCollapsed } = useSidebarContext();
|
||||
const {
|
||||
sidebarSuppressed,
|
||||
expandSidebar,
|
||||
} = usePanelManager();
|
||||
|
||||
const { openDetailPanel } = surfaceConfig;
|
||||
const sidebarHidden = sidebarCollapsed || sidebarSuppressed;
|
||||
|
||||
const { surface } = useWorkspaceSurface({
|
||||
sidebarHidden,
|
||||
onExpandSidebar: expandSidebar,
|
||||
...surfaceConfig,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('has-main-content');
|
||||
return () => {
|
||||
document.body.classList.remove('has-main-content');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderSurface = () => {
|
||||
const detailMode = surface?.detailMode ?? null;
|
||||
const layoutClass = `documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}${detailMode === 'overlay' ? ' documents-main--overlay-detail' : ''}`;
|
||||
const sidebarNode = !sidebarHidden ? <Sidebar /> : null;
|
||||
const surfaceDetail = surface && (surface as { detail?: ReactNode }).detail ? (surface as { detail?: ReactNode }).detail : null;
|
||||
const surfaceBody = surface ? surface.content : null;
|
||||
|
||||
return (
|
||||
<main className={layoutClass}>
|
||||
{sidebarNode}
|
||||
<div className="main-content">
|
||||
{surfaceBody}
|
||||
</div>
|
||||
{surfaceDetail}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentOpenProvider
|
||||
onOpenViewer={onNavigate}
|
||||
onOpenFullscreenPreview={openFullscreenPreview}
|
||||
onOpenDetailPanel={openDetailPanel}
|
||||
>
|
||||
{renderSurface()}
|
||||
</DocumentOpenProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsRouteContent: React.FC = () => {
|
||||
const {
|
||||
surfaceConfig,
|
||||
documentsFilter,
|
||||
} = useDocumentsShell();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleDocumentNavigate = useCallback((documentId: string) => {
|
||||
navigate(`/documents/${documentId}`);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<DocumentsFilterProvider value={documentsFilter}>
|
||||
<FullscreenPreviewProvider
|
||||
onNavigate={handleDocumentNavigate}
|
||||
>
|
||||
<DocumentsInner
|
||||
surfaceConfig={surfaceConfig}
|
||||
onNavigate={handleDocumentNavigate}
|
||||
/>
|
||||
</FullscreenPreviewProvider>
|
||||
</DocumentsFilterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsRoute: React.FC = () => {
|
||||
const { surfaceConfig } = useDocumentsShell();
|
||||
const { detailPanelOpen, closeDetailPanel } = surfaceConfig;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<PanelManagerProvider
|
||||
isOpen={detailPanelOpen}
|
||||
onClose={closeDetailPanel}
|
||||
>
|
||||
<DocumentsRouteContent />
|
||||
</PanelManagerProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsRoute;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DropOverlay;
|
||||
@@ -0,0 +1,535 @@
|
||||
/* global PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import LoginView from '../login/LoginView';
|
||||
import useApiError from '../hooks/useApiError';
|
||||
import {
|
||||
isWebAuthnAvailable,
|
||||
preparePublicKeyRequestOptions,
|
||||
preparePublicKeyCreationOptions,
|
||||
serializeAuthenticationCredential,
|
||||
serializeRegistrationCredential,
|
||||
} from '../utils/webauthn';
|
||||
import { useAppDispatch, useAppState } from '../lib/store/appState';
|
||||
import {
|
||||
finishPasskeyLogin,
|
||||
finishSignup,
|
||||
performLogin,
|
||||
selectTenant,
|
||||
startPasskeyLogin,
|
||||
startSignup,
|
||||
} from '../lib/api/apiClient';
|
||||
|
||||
type StatusVariant = 'info' | 'success' | 'error';
|
||||
|
||||
interface StatusMessage {
|
||||
message: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface TenantSelectionState {
|
||||
selectionToken?: string | null;
|
||||
tenants?: TenantOption[];
|
||||
}
|
||||
|
||||
type AuthResponse = {
|
||||
access_token?: string;
|
||||
tenant?: TenantOption | 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<StatusMessage | null>(null);
|
||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||
const passkeySupported = isWebAuthnAvailable();
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const signupSupported = passkeySupported;
|
||||
const [signupLoading, setSignupLoading] = useState(false);
|
||||
const [magicLoginPending, setMagicLoginPending] = useState(false);
|
||||
const magicLoginParams = useMemo(() => {
|
||||
const extract = (searchString) => {
|
||||
const params = new URLSearchParams(searchString || '');
|
||||
const token = (params.get('magic_token') || '').trim();
|
||||
const usernameHint = (params.get('username') || '').trim();
|
||||
const preferredTenantId = (params.get('preferred_tenant_id') || '').trim();
|
||||
return {
|
||||
token: token || null,
|
||||
username: usernameHint || null,
|
||||
preferredTenantId: preferredTenantId || null,
|
||||
};
|
||||
};
|
||||
|
||||
let combined = extract(location.search);
|
||||
|
||||
const hash = window.location.hash || '';
|
||||
const queryIndex = hash.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
const hashQuery = hash.slice(queryIndex + 1);
|
||||
const hashParams = extract(`?${hashQuery}`);
|
||||
combined = {
|
||||
token: combined.token || hashParams.token,
|
||||
username: combined.username || hashParams.username,
|
||||
preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId,
|
||||
};
|
||||
}
|
||||
if (!combined.token) {
|
||||
const searchParams = extract(window.location.search);
|
||||
combined = {
|
||||
token: combined.token || searchParams.token,
|
||||
username: combined.username || searchParams.username,
|
||||
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
|
||||
};
|
||||
}
|
||||
|
||||
return combined;
|
||||
}, [location.search]);
|
||||
const preferredTenantRef = useRef(null);
|
||||
const attemptedMagicTokenRef = useRef(null);
|
||||
const magicLoginPendingRef = useRef(false);
|
||||
const appStatusRef = useRef(appStatus);
|
||||
|
||||
const {
|
||||
token: magicToken,
|
||||
username: magicUsername,
|
||||
preferredTenantId: magicPreferredTenantId,
|
||||
} = magicLoginParams;
|
||||
|
||||
useEffect(() => {
|
||||
appStatusRef.current = appStatus;
|
||||
}, [appStatus]);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
const handleLoginApiReport = useCallback(
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
[setStatusMessage],
|
||||
);
|
||||
|
||||
const reportLoginError = useApiError({
|
||||
onReport: handleLoginApiReport,
|
||||
});
|
||||
|
||||
const notifyLoginError = useCallback(
|
||||
(error, fallbackMessage, variant = 'error') =>
|
||||
reportLoginError(error, { message: fallbackMessage, variant }),
|
||||
[reportLoginError],
|
||||
);
|
||||
|
||||
const clearMagicParamsFromUrl = useCallback(() => {
|
||||
const removableKeys = ['magic_token', 'username', 'preferred_tenant_id'];
|
||||
const currentSearch = new URLSearchParams(window.location.search);
|
||||
let searchChanged = false;
|
||||
removableKeys.forEach((key) => {
|
||||
if (currentSearch.has(key)) {
|
||||
currentSearch.delete(key);
|
||||
searchChanged = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hash = window.location.hash || '';
|
||||
let nextHash = hash;
|
||||
const hashQuestionIndex = hash.indexOf('?');
|
||||
if (hashQuestionIndex !== -1) {
|
||||
const hashPath = hash.slice(0, hashQuestionIndex);
|
||||
const hashQuery = hash.slice(hashQuestionIndex + 1);
|
||||
const hashParams = new URLSearchParams(hashQuery);
|
||||
let hashChanged = false;
|
||||
removableKeys.forEach((key) => {
|
||||
if (hashParams.has(key)) {
|
||||
hashParams.delete(key);
|
||||
hashChanged = true;
|
||||
}
|
||||
});
|
||||
if (hashChanged) {
|
||||
const nextQuery = hashParams.toString();
|
||||
nextHash = nextQuery ? `${hashPath}?${nextQuery}` : hashPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!searchChanged && nextHash === hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSearch = currentSearch.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`;
|
||||
window.history.replaceState(window.history.state, document.title, nextUrl);
|
||||
}, []);
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenant) => {
|
||||
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSelectingTenantId(tenant.id);
|
||||
const data = await selectTenant(
|
||||
{ tenant_id: tenant.id },
|
||||
tenantSelection.selectionToken,
|
||||
) as AuthResponse;
|
||||
|
||||
const accessToken = data?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error('Invalid tenant selection response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: accessToken,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Failed to finalize login.';
|
||||
notifyLoginError(error, message, 'error');
|
||||
} finally {
|
||||
setSelectingTenantId(null);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyLoginError, setStatusMessage, tenantSelection],
|
||||
);
|
||||
|
||||
const handleCancelSelection = useCallback(() => {
|
||||
appDispatch({ type: 'CLEAR_TENANT_SELECTION' });
|
||||
setStatusMessage(null);
|
||||
}, [appDispatch, setStatusMessage]);
|
||||
|
||||
const handlePasskeyLogin = useCallback(
|
||||
async (rawUsername) => {
|
||||
const username = rawUsername?.trim?.() || '';
|
||||
if (!username) {
|
||||
setStatusMessage('Enter your username before using a passkey.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyLoading(true);
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
try {
|
||||
const startData = await startPasskeyLogin(username);
|
||||
const challengeId = (startData as { challengeId?: string })?.challengeId;
|
||||
const publicKeyOptions = (startData as { publicKey?: PublicKeyCredentialRequestOptions })?.publicKey;
|
||||
|
||||
if (!challengeId || !publicKeyOptions) {
|
||||
throw new Error('Invalid passkey challenge response.');
|
||||
}
|
||||
|
||||
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
|
||||
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
|
||||
const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null;
|
||||
|
||||
if (!assertion) {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(assertion instanceof PublicKeyCredential)) {
|
||||
setStatusMessage('Unexpected credential response.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAuthenticationCredential(assertion);
|
||||
const finishPayload = {
|
||||
challengeId,
|
||||
credential: serialized,
|
||||
};
|
||||
|
||||
const finishData = await finishPasskeyLogin(finishPayload) as AuthResponse;
|
||||
|
||||
if (finishData?.access_token && Array.isArray(finishData.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: finishData.access_token,
|
||||
tenants: finishData.tenants || [],
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finishData?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: finishData.access_token,
|
||||
tenant: finishData.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
} else if (error?.response?.status === 404) {
|
||||
setStatusMessage('No passkey registered for this username. Create an account first.', 'error');
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' });
|
||||
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
|
||||
setStatusMessage(error.response.data.error, 'error');
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error });
|
||||
} else {
|
||||
const message = error?.response?.data?.error || error.message || 'Passkey login failed.';
|
||||
notifyLoginError(error, message);
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!magicToken) {
|
||||
return;
|
||||
}
|
||||
if (magicLoginPendingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (attemptedMagicTokenRef.current === magicToken) {
|
||||
return;
|
||||
}
|
||||
if (appStatusRef.current === 'authenticated') {
|
||||
clearMagicParamsFromUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
attemptedMagicTokenRef.current = magicToken;
|
||||
|
||||
const attemptMagicLogin = async () => {
|
||||
magicLoginPendingRef.current = true;
|
||||
setMagicLoginPending(true);
|
||||
preferredTenantRef.current = magicPreferredTenantId;
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
setStatusMessage('Signing you in…', 'info');
|
||||
|
||||
try {
|
||||
const payload: {
|
||||
magic_token: string;
|
||||
username?: string;
|
||||
preferred_tenant_id?: string;
|
||||
} = {
|
||||
magic_token: magicToken,
|
||||
};
|
||||
if (magicUsername) {
|
||||
payload.username = magicUsername;
|
||||
}
|
||||
if (magicPreferredTenantId) {
|
||||
payload.preferred_tenant_id = magicPreferredTenantId;
|
||||
}
|
||||
|
||||
const data = await performLogin(payload) as AuthResponse;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.access_token && Array.isArray(data.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants || [],
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
preferredTenantRef.current = null;
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const message = error?.response?.data?.error || 'Magic link login failed.';
|
||||
notifyLoginError(error, message);
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
preferredTenantRef.current = null;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setMagicLoginPending(false);
|
||||
magicLoginPendingRef.current = false;
|
||||
clearMagicParamsFromUrl();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptMagicLogin();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
magicLoginPendingRef.current = false;
|
||||
setMagicLoginPending(false);
|
||||
};
|
||||
}, [
|
||||
appDispatch,
|
||||
clearMagicParamsFromUrl,
|
||||
magicToken,
|
||||
magicUsername,
|
||||
magicPreferredTenantId,
|
||||
notifyLoginError,
|
||||
setStatusMessage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferredTenantId = preferredTenantRef.current;
|
||||
if (!preferredTenantId) {
|
||||
return;
|
||||
}
|
||||
if (!tenantSelection?.tenants?.length) {
|
||||
return;
|
||||
}
|
||||
if (selectingTenantId) {
|
||||
return;
|
||||
}
|
||||
const match = tenantSelection.tenants.find((tenant) => tenant.id === preferredTenantId);
|
||||
if (!match) {
|
||||
preferredTenantRef.current = null;
|
||||
return;
|
||||
}
|
||||
handleTenantSelect(match);
|
||||
preferredTenantRef.current = null;
|
||||
}, [handleTenantSelect, selectingTenantId, tenantSelection]);
|
||||
|
||||
const handleSignup = useCallback(
|
||||
async (rawUsername) => {
|
||||
const username = rawUsername?.trim?.() || '';
|
||||
if (!username) {
|
||||
setStatusMessage('Choose a username to create your account.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSignupLoading(true);
|
||||
try {
|
||||
const startData = await startSignup(username) as {
|
||||
signup_token?: string;
|
||||
challenge?: { challengeId?: string; publicKey?: PublicKeyCredentialCreationOptions };
|
||||
};
|
||||
const signupToken = startData.signup_token;
|
||||
const challengePayload = startData.challenge;
|
||||
const challengeId = challengePayload?.challengeId;
|
||||
const publicKeyOptions = challengePayload?.publicKey;
|
||||
|
||||
if (!signupToken || !challengeId || !publicKeyOptions) {
|
||||
throw new Error('Invalid signup challenge response.');
|
||||
}
|
||||
|
||||
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
|
||||
const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null;
|
||||
|
||||
if (!credential) {
|
||||
setStatusMessage('Signup cancelled.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
setStatusMessage('Unexpected credential response.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeRegistrationCredential(credential);
|
||||
const finishPayload = {
|
||||
signup_token: signupToken,
|
||||
credential: serialized,
|
||||
};
|
||||
|
||||
const finishData = await finishSignup(finishPayload) as AuthResponse;
|
||||
|
||||
if (finishData?.access_token && Array.isArray(finishData.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: finishData.access_token,
|
||||
tenants: finishData.tenants || [],
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finishData?.access_token) {
|
||||
throw new Error('Invalid signup response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: finishData.access_token,
|
||||
tenant: finishData.tenant || null,
|
||||
});
|
||||
setStatusMessage('Account created. Welcome!', 'success');
|
||||
} catch (error) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setStatusMessage('Signup cancelled.', 'info');
|
||||
} else if (error?.response?.status === 409) {
|
||||
setStatusMessage('This passkey is already registered. Try signing in instead.', 'error');
|
||||
} else if (error?.response?.data?.error) {
|
||||
setStatusMessage(error.response.data.error, 'error');
|
||||
} else {
|
||||
const message = error?.message || 'Failed to create account.';
|
||||
setStatusMessage(message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setSignupLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, passkeySupported, setStatusMessage],
|
||||
);
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const target = String(location.state?.from ?? '');
|
||||
if (target.startsWith('/')) {
|
||||
return target;
|
||||
}
|
||||
return '/documents';
|
||||
}, [location.state]);
|
||||
|
||||
if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||
return <Navigate to={redirectTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<LoginView
|
||||
status={status}
|
||||
tenantSelection={tenantSelection}
|
||||
onSelectTenant={handleTenantSelect}
|
||||
onCancelSelection={handleCancelSelection}
|
||||
selectingTenantId={selectingTenantId}
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyLoading={passkeyLoading}
|
||||
onSignup={handleSignup}
|
||||
signupSupported={signupSupported}
|
||||
signupLoading={signupLoading}
|
||||
magicLoginPending={magicLoginPending}
|
||||
initialUsername={magicLoginParams.username || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginRoute;
|
||||
@@ -0,0 +1,406 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import {
|
||||
DEFAULT_DETAIL_WIDTH,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MINIMAL_FREE_RATIO,
|
||||
MINIMUM_MAIN_CONTENT_WIDTH,
|
||||
PANEL_LIMITS,
|
||||
PANEL_STORAGE_KEYS,
|
||||
SIDEBAR_SOLO_THRESHOLD,
|
||||
type PanelKey,
|
||||
} from '../constants/layout';
|
||||
import { createSafeContext } from '../utils/createSafeContext';
|
||||
|
||||
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, usePanelManager] = createSafeContext<PanelManagerContextValue>('PanelManager');
|
||||
|
||||
const clampPanelWidth = (panel: PanelKey, value: number): number => {
|
||||
const numeric = Number(value);
|
||||
const limits = PANEL_LIMITS[panel];
|
||||
const viewport = window.innerWidth;
|
||||
const minLimit = Math.max(0, limits.minPx);
|
||||
const ratioMax = Math.round(viewport * limits.maxRatio);
|
||||
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: PanelKey, fallback: number): number => {
|
||||
const raw = window.localStorage.getItem(PANEL_STORAGE_KEYS[panel]);
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const persistWidth = (panel: PanelKey, value: number): void => {
|
||||
window.localStorage.setItem(PANEL_STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
};
|
||||
|
||||
const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => {
|
||||
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
||||
const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`;
|
||||
document.documentElement.style.setProperty(varName, resolvedValue);
|
||||
};
|
||||
|
||||
interface PanelManagerProviderProps {
|
||||
children: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children, isOpen, onClose }) => {
|
||||
const { collapsed, setCollapsed } = useSidebarContext();
|
||||
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));
|
||||
const [resizingPanel, setResizingPanel] = useState(null);
|
||||
const [sidebarSuppressed, setSidebarSuppressed] = useState(false);
|
||||
const [detailPanelOpen, setDetailPanelOpen] = useState(Boolean(isOpen));
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen !== undefined) {
|
||||
setDetailPanelOpen(isOpen);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
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;
|
||||
handler?.();
|
||||
onClose?.();
|
||||
if (isOpen === undefined) {
|
||||
setDetailPanelOpen(false);
|
||||
}
|
||||
}, [onClose, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
panelWidthsRef.current.sidebar = sidebarWidth;
|
||||
applyPanelWidthToRoot('sidebar', sidebarWidth, true);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
panelWidthsRef.current.detail = detailWidth;
|
||||
applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen);
|
||||
}, [detailWidth, detailPanelOpen]);
|
||||
|
||||
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;
|
||||
const freeSpace = viewportWidth - sidebarWidth - detailWidth;
|
||||
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') {
|
||||
setSidebarSuppressed(!meetsThreshold);
|
||||
} else if (action === 'closed') {
|
||||
setSidebarSuppressed(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) {
|
||||
closeDetailPanel();
|
||||
}
|
||||
}, [collapsed, closeDetailPanel, detailPanelOpen]);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
if (!collapsed) {
|
||||
setCollapsed(true);
|
||||
handlePanelLayoutChange('sidebar', 'closed');
|
||||
}
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const setPanelWidth = useCallback(
|
||||
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
|
||||
const clamped = clampPanelWidth(panel, width);
|
||||
|
||||
if (panel === 'sidebar') {
|
||||
setSidebarWidthState((prev) => (prev === clamped ? prev : clamped));
|
||||
} else {
|
||||
setDetailWidthState((prev) => (prev === clamped ? prev : clamped));
|
||||
}
|
||||
panelWidthsRef.current[panel] = clamped;
|
||||
if (commit) {
|
||||
preferredPanelWidthsRef.current[panel] = clamped;
|
||||
persistWidth(panel, clamped);
|
||||
}
|
||||
|
||||
if (log) {
|
||||
handlePanelLayoutChange(panel, 'resized', clamped);
|
||||
}
|
||||
return clamped;
|
||||
},
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
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(
|
||||
(active) => {
|
||||
if (isOpen === undefined) {
|
||||
setDetailPanelOpen(Boolean(active));
|
||||
}
|
||||
if (!active) {
|
||||
onClose?.();
|
||||
}
|
||||
handlePanelLayoutChange('detail', active ? 'opened' : 'closed');
|
||||
},
|
||||
[handlePanelLayoutChange, isOpen, onClose],
|
||||
);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
handlePanelLayoutChange('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const startPanelResize = useCallback((panel) => {
|
||||
setResizingPanel(panel);
|
||||
}, []);
|
||||
|
||||
const stopPanelResize = useCallback(() => {
|
||||
setResizingPanel(null);
|
||||
}, []);
|
||||
|
||||
const getPanelWidth = useCallback((panel) => panelWidthsRef.current[panel] || 0, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
sidebarWidth,
|
||||
detailWidth,
|
||||
sidebarSuppressed,
|
||||
resizingPanel,
|
||||
setPanelWidth,
|
||||
startPanelResize,
|
||||
stopPanelResize,
|
||||
getPanelWidth,
|
||||
setDetailActive,
|
||||
closeDetailPanel,
|
||||
collapseSidebar,
|
||||
expandSidebar,
|
||||
registerDetailCloseHandler,
|
||||
detailPanelOpen,
|
||||
}),
|
||||
[
|
||||
sidebarWidth,
|
||||
detailWidth,
|
||||
sidebarSuppressed,
|
||||
resizingPanel,
|
||||
setPanelWidth,
|
||||
startPanelResize,
|
||||
stopPanelResize,
|
||||
getPanelWidth,
|
||||
setDetailActive,
|
||||
closeDetailPanel,
|
||||
collapseSidebar,
|
||||
expandSidebar,
|
||||
registerDetailCloseHandler,
|
||||
detailPanelOpen,
|
||||
],
|
||||
);
|
||||
|
||||
return <PanelManagerContext.Provider value={contextValue}>{children}</PanelManagerContext.Provider>;
|
||||
};
|
||||
|
||||
export { usePanelManager };
|
||||
|
||||
export const usePanelResizeBindings = (
|
||||
panel: PanelKey,
|
||||
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
|
||||
): PanelResizeBindings => {
|
||||
const {
|
||||
sidebarWidth,
|
||||
detailWidth,
|
||||
resizingPanel,
|
||||
setPanelWidth,
|
||||
startPanelResize,
|
||||
stopPanelResize,
|
||||
getPanelWidth,
|
||||
} = usePanelManager();
|
||||
|
||||
const liveWidth = panel === 'sidebar' ? sidebarWidth : detailWidth;
|
||||
const latestWidthRef = useRef(liveWidth);
|
||||
const cleanupRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = liveWidth;
|
||||
}, [liveWidth]);
|
||||
|
||||
const teardownListeners = useCallback(() => {
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
cleanupRef.current = null;
|
||||
}
|
||||
stopPanelResize();
|
||||
}, [stopPanelResize]);
|
||||
|
||||
useEffect(() => () => teardownListeners(), [teardownListeners]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !panelRef?.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
const startWidth = rect?.width ?? getPanelWidth(panel);
|
||||
const pointerId = event.pointerId ?? 'mouse';
|
||||
const startX = event.clientX;
|
||||
startPanelResize(panel);
|
||||
event.currentTarget?.setPointerCapture?.(pointerId);
|
||||
let lastWidth = startWidth;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const delta = panel === 'sidebar'
|
||||
? moveEvent.clientX - startX
|
||||
: startX - moveEvent.clientX;
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
|
||||
latestWidthRef.current = lastWidth;
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget?.releasePointerCapture?.(pointerId);
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
setPanelWidth(panel, lastWidth);
|
||||
teardownListeners();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
cleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
panelRef,
|
||||
panel,
|
||||
getPanelWidth,
|
||||
startPanelResize,
|
||||
setPanelWidth,
|
||||
teardownListeners,
|
||||
],
|
||||
);
|
||||
|
||||
const panelStyle = enabled ? { width: `${liveWidth}px` } : undefined;
|
||||
|
||||
const handleProps = enabled
|
||||
? {
|
||||
onPointerDown: handlePointerDown,
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
panelStyle,
|
||||
handleProps,
|
||||
isPanelResizing: resizingPanel === panel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import SettingsModal from '../settings/SettingsModal';
|
||||
import { useAppShell } from '../lib/context/AppShellContext';
|
||||
import useApiTokens from '../settings/useApiTokens';
|
||||
import useCapabilitySets from '../settings/useCapabilitySets';
|
||||
import useCapabilities from '../settings/useCapabilities';
|
||||
|
||||
interface SettingsRouteProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
|
||||
const shell = useAppShell() as any;
|
||||
const { token } = shell.session || {};
|
||||
const { notifyApiError, setStatusMessage } = shell.ui || {};
|
||||
const {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = shell.passkeys || {};
|
||||
|
||||
const {
|
||||
tokens,
|
||||
loading: tokensLoading,
|
||||
creating: creatingToken,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
createdSecret,
|
||||
refresh: refreshTokens,
|
||||
create: createToken,
|
||||
revoke: revokeToken,
|
||||
regenerate: regenerateToken,
|
||||
dismissSecret,
|
||||
} = useApiTokens({ token, notifyApiError, setStatusMessage });
|
||||
|
||||
const {
|
||||
capabilitySets,
|
||||
capabilitySetsLoading,
|
||||
creatingCapabilitySet,
|
||||
savingCapabilitySetId,
|
||||
deletingCapabilitySetId,
|
||||
supportsCapabilitySetLabels,
|
||||
refreshCapabilitySets,
|
||||
createCapabilitySet,
|
||||
updateCapabilitySet,
|
||||
deleteCapabilitySet,
|
||||
} = useCapabilitySets({ token, notifyApiError, setStatusMessage });
|
||||
|
||||
const {
|
||||
capabilities,
|
||||
capabilitiesLoading,
|
||||
refreshCapabilities,
|
||||
} = useCapabilities({ notifyApiError, token });
|
||||
|
||||
useEffect(() => {
|
||||
refreshTokens();
|
||||
refreshCapabilitySets();
|
||||
refreshCapabilities();
|
||||
refreshPasskeys();
|
||||
}, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
refreshTokens();
|
||||
refreshCapabilitySets();
|
||||
refreshCapabilities();
|
||||
}, [refreshTokens, refreshCapabilitySets, refreshCapabilities]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dismissSecret();
|
||||
onClose?.();
|
||||
}, [dismissSecret, onClose]);
|
||||
|
||||
useEffect(() => () => {
|
||||
dismissSecret();
|
||||
}, [dismissSecret]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
tokens={tokens}
|
||||
loading={tokensLoading}
|
||||
creating={creatingToken}
|
||||
deletingId={deletingId}
|
||||
regeneratingId={regeneratingId}
|
||||
onRefresh={handleRefresh}
|
||||
onCreate={createToken}
|
||||
onDelete={revokeToken}
|
||||
onRegenerate={regenerateToken}
|
||||
createdToken={createdSecret}
|
||||
onDismissCreatedToken={dismissSecret}
|
||||
capabilitySets={capabilitySets}
|
||||
capabilitySetsLoading={capabilitySetsLoading}
|
||||
creatingCapabilitySet={creatingCapabilitySet}
|
||||
savingCapabilitySetId={savingCapabilitySetId}
|
||||
deletingCapabilitySetId={deletingCapabilitySetId}
|
||||
supportsCapabilitySetLabels={supportsCapabilitySetLabels}
|
||||
onRefreshCapabilitySets={refreshCapabilitySets}
|
||||
capabilities={capabilities}
|
||||
capabilitiesLoading={capabilitiesLoading}
|
||||
onRefreshCapabilities={refreshCapabilities}
|
||||
onCreateCapabilitySet={createCapabilitySet}
|
||||
onUpdateCapabilitySet={updateCapabilitySet}
|
||||
onDeleteCapabilitySet={deleteCapabilitySet}
|
||||
passkeys={passkeys}
|
||||
passkeysSupported={passkeysSupported}
|
||||
passkeysLoading={passkeysLoading}
|
||||
registeringPasskey={registeringPasskey}
|
||||
revokingPasskeyId={revokingPasskeyId}
|
||||
onRefreshPasskeys={refreshPasskeys}
|
||||
onRegisterPasskey={registerPasskey}
|
||||
onRevokePasskey={revokePasskey}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsRoute;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CloseIcon,
|
||||
LoaderIcon,
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
BottombarCollapseIcon,
|
||||
BottombarExpandIcon,
|
||||
} from '../components/icons';
|
||||
import PanelHeader from '../components/PanelHeader';
|
||||
|
||||
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: UploadStatus;
|
||||
error?: string | null;
|
||||
document?: { id?: string; title?: string };
|
||||
conflictDocumentId?: string;
|
||||
}
|
||||
|
||||
interface UploadQueueOverlayProps {
|
||||
queue?: UploadQueueItem[];
|
||||
onClearQueue?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
|
||||
pending: {
|
||||
label: 'Queued',
|
||||
tone: 'muted',
|
||||
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
||||
},
|
||||
uploading: {
|
||||
label: 'Uploading',
|
||||
tone: 'accent',
|
||||
icon: <LoaderIcon className="icon icon--spin" size={16} />,
|
||||
},
|
||||
success: {
|
||||
label: 'Uploaded',
|
||||
tone: 'success',
|
||||
icon: <CheckIcon size={16} />,
|
||||
},
|
||||
duplicate: {
|
||||
label: 'Duplicate',
|
||||
tone: 'info',
|
||||
icon: <InfoIcon size={16} />,
|
||||
},
|
||||
error: {
|
||||
label: 'Failed',
|
||||
tone: 'danger',
|
||||
icon: <WarningIcon size={16} />,
|
||||
},
|
||||
};
|
||||
|
||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length > 0) {
|
||||
setDismissed(false);
|
||||
}
|
||||
}, [queue.length]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!queue.length) {
|
||||
return 'No uploads';
|
||||
}
|
||||
const uploadingCount = queue.filter((item) => item.status === 'uploading').length;
|
||||
const pendingCount = queue.filter((item) => item.status === 'pending').length;
|
||||
const errorCount = queue.filter((item) => item.status === 'error').length;
|
||||
if (uploadingCount > 0 || pendingCount > 0) {
|
||||
return `${uploadingCount} uploading · ${pendingCount} queued`;
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
return `${errorCount} failed · ${queue.length} total`;
|
||||
}
|
||||
return `${queue.length} completed`;
|
||||
}, [queue]);
|
||||
|
||||
const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
|
||||
|
||||
const handleDismissOverlay = () => {
|
||||
if (!queue.length) {
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasActiveUploads) {
|
||||
const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onClearQueue?.();
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (!queue.length || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}>
|
||||
<PanelHeader
|
||||
title={(
|
||||
<span>
|
||||
<span>Uploads</span>
|
||||
<span className="panel-header__subtitle">{summary}</span>
|
||||
</span>
|
||||
)}
|
||||
actions={(
|
||||
<div className="upload-queue-overlay__controls">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button ghost"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'}
|
||||
>
|
||||
{collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={handleDismissOverlay}
|
||||
aria-label="Clear uploads and hide overlay"
|
||||
>
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{!collapsed ? (
|
||||
<div className="upload-queue-overlay__body">
|
||||
<ul className="upload-queue-overlay__list">
|
||||
{[...queue]
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item) => {
|
||||
const meta = STATUS_META[item.status] || STATUS_META.pending;
|
||||
const fileLabel = item.name;
|
||||
const documentTitle = item.document?.title || null;
|
||||
const duplicateLabel = item.status === 'duplicate' ? documentTitle : null;
|
||||
const documentId = item.document?.id || item.conflictDocumentId || null;
|
||||
const hasLink = Boolean(documentId);
|
||||
const handleNavigate = () => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
navigate(`/documents/${documentId}`);
|
||||
};
|
||||
return (
|
||||
<li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}>
|
||||
<span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="upload-queue-overlay__details">
|
||||
{item.status === 'success' && hasLink ? (
|
||||
<button
|
||||
type="button"
|
||||
className="upload-queue-overlay__name-link"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
{fileLabel}
|
||||
</button>
|
||||
) : (
|
||||
<div className="upload-queue-overlay__name">
|
||||
{fileLabel}
|
||||
</div>
|
||||
)}
|
||||
<div className="upload-queue-overlay__meta-line">
|
||||
{item.status === 'duplicate' && duplicateLabel ? (
|
||||
<span className="upload-queue-overlay__meta-duplicate">
|
||||
Duplicate of{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="upload-queue-overlay__meta-link"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
{duplicateLabel}
|
||||
</button>
|
||||
</span>
|
||||
) : item.status === 'error' && item.error ? (
|
||||
<span className="upload-queue-overlay__meta-error" title={item.error}>
|
||||
{item.error}
|
||||
</span>
|
||||
) : (
|
||||
<span>{meta.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadQueueOverlay;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import type { useWorkspaceSelection } from './useWorkspaceSelection';
|
||||
import { createSafeContext } from '../utils/createSafeContext';
|
||||
|
||||
type WorkspaceSelectionValue = ReturnType<typeof useWorkspaceSelection>;
|
||||
|
||||
const [WorkspaceSelectionContext, useWorkspaceSelectionContext] = createSafeContext<WorkspaceSelectionValue>('WorkspaceSelection');
|
||||
|
||||
interface WorkspaceSelectionProviderProps {
|
||||
value: WorkspaceSelectionValue;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WorkspaceSelectionProvider: React.FC<WorkspaceSelectionProviderProps> = ({ value, children }) => (
|
||||
<WorkspaceSelectionContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkspaceSelectionContext.Provider>
|
||||
);
|
||||
|
||||
export { useWorkspaceSelectionContext };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DocumentId, FolderId } from '../types/identifiers';
|
||||
import { ENTRY_KEY_SEPARATOR } from '../constants/app';
|
||||
|
||||
// Entry key utilities for workspace selection
|
||||
// Entry keys are strings in the format "document:id" or "folder:id"
|
||||
|
||||
// Create entry key strings
|
||||
export const createDocumentEntryKey = (documentId: DocumentId): string =>
|
||||
`document${ENTRY_KEY_SEPARATOR}${documentId}`;
|
||||
|
||||
export const createFolderEntryKey = (folderId: FolderId): string =>
|
||||
`folder${ENTRY_KEY_SEPARATOR}${folderId}`;
|
||||
|
||||
// Type guards for entry key strings
|
||||
export const isDocumentEntry = (key: string): boolean =>
|
||||
key.split(ENTRY_KEY_SEPARATOR, 1)[0] === 'document';
|
||||
|
||||
export const isFolderEntry = (key: string): boolean =>
|
||||
key.split(ENTRY_KEY_SEPARATOR, 1)[0] === 'folder';
|
||||
|
||||
// Extract ID from entry key string
|
||||
export const getEntryId = (key: string): string => {
|
||||
const parts = key.split(ENTRY_KEY_SEPARATOR);
|
||||
return parts.slice(1).join(ENTRY_KEY_SEPARATOR);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
interface DetailDocument {
|
||||
id?: Identifier;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDetailPanelOptions {
|
||||
documentLookup: Map<Identifier, DetailDocument>;
|
||||
}
|
||||
|
||||
export const useDetailPanel = ({
|
||||
documentLookup,
|
||||
}: UseDetailPanelOptions) => {
|
||||
const [detailPanelDocId, setDetailPanelDocId] = useState<Identifier | null>(null);
|
||||
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
|
||||
|
||||
const detailPanelOpen = detailPanelDocId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailPanelDocId) {
|
||||
setDetailPanelDocument(null);
|
||||
return;
|
||||
}
|
||||
const resolved = documentLookup.get(detailPanelDocId) ?? null;
|
||||
if (resolved !== detailPanelDocument) {
|
||||
setDetailPanelDocument(resolved);
|
||||
}
|
||||
}, [detailPanelDocId, documentLookup, detailPanelDocument]);
|
||||
|
||||
const openDetailPanel = useCallback(
|
||||
(documentId: Identifier) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
setDetailPanelDocId(documentId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeDetailPanel = useCallback(() => {
|
||||
setDetailPanelDocId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
detailPanelOpen,
|
||||
detailPanelDocument,
|
||||
openDetailPanel,
|
||||
closeDetailPanel,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
createDocumentEntryKey,
|
||||
createFolderEntryKey,
|
||||
isDocumentEntry,
|
||||
isFolderEntry,
|
||||
getEntryId,
|
||||
} from './entryKey';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
|
||||
interface SelectionEventLike {
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
preventDefault?: () => void;
|
||||
}
|
||||
|
||||
interface UseDocumentSelectionOptions {
|
||||
initialEntries?: string[];
|
||||
}
|
||||
|
||||
interface ApplySelectionOptions {
|
||||
anchor: string | null;
|
||||
interactedKeys?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_ENTRIES: string[] = [];
|
||||
|
||||
export const useDocumentSelection = ({
|
||||
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
||||
}: UseDocumentSelectionOptions = {}) => {
|
||||
const [selectedEntries, setSelectedEntries] = useState<string[]>(initialEntries);
|
||||
const [selectionOrder, setSelectionOrder] = useState<string[]>(initialEntries);
|
||||
const selectionOrderRef = useRef<string[]>(initialEntries);
|
||||
const selectionAnchorRef = useRef<string | null>(null);
|
||||
const selectionInitializedRef = useRef(false);
|
||||
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
|
||||
const [focusedEntryKey, setFocusedEntryKey] = useState<string | null>(null);
|
||||
|
||||
const visibleEntryKeySetRef = useRef<Set<string>>(new Set());
|
||||
const navigableEntryKeysRef = useRef<string[]>([]);
|
||||
|
||||
const configureSelectionEnvironment = useCallback(({
|
||||
visibleEntryKeySet,
|
||||
navigableEntryKeys,
|
||||
}: {
|
||||
visibleEntryKeySet?: Set<string>;
|
||||
navigableEntryKeys?: string[];
|
||||
}) => {
|
||||
if (visibleEntryKeySet) {
|
||||
visibleEntryKeySetRef.current = visibleEntryKeySet;
|
||||
}
|
||||
if (Array.isArray(navigableEntryKeys)) {
|
||||
navigableEntryKeysRef.current = navigableEntryKeys;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSelectionOrder = useCallback((nextSelection: string[], interactedKeys: string[] = []) => {
|
||||
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);
|
||||
|
||||
const base = previousOrder.filter((id) => !interacted.includes(id));
|
||||
const result = [...base];
|
||||
|
||||
interacted.forEach((id) => {
|
||||
if (nextSet.has(id) && !result.includes(id)) {
|
||||
result.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
nextSelection.forEach((id) => {
|
||||
if (!result.includes(id)) {
|
||||
result.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
result.length !== selectionOrderRef.current.length
|
||||
|| result.some((id, index) => selectionOrderRef.current[index] !== id)
|
||||
) {
|
||||
selectionOrderRef.current = result;
|
||||
setSelectionOrder(result);
|
||||
} else {
|
||||
selectionOrderRef.current = result;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(
|
||||
entryKeys: Array<string | null>,
|
||||
{ anchor = null, interactedKeys = [] }: ApplySelectionOptions = { anchor: null, interactedKeys: [] },
|
||||
) => {
|
||||
const visibleEntryKeySet = visibleEntryKeySetRef.current;
|
||||
const unique: string[] = [];
|
||||
|
||||
(entryKeys || []).forEach((key) => {
|
||||
if (!key) return;
|
||||
let canonicalKey: string | null = null;
|
||||
if (visibleEntryKeySet.has(key)) {
|
||||
canonicalKey = key;
|
||||
} else if (isDocumentEntry(key)) {
|
||||
const id = getEntryId(key);
|
||||
canonicalKey = id ? createDocumentEntryKey(id) : null;
|
||||
} else if (isFolderEntry(key)) {
|
||||
const id = getEntryId(key);
|
||||
canonicalKey = id ? createFolderEntryKey(id) : null;
|
||||
}
|
||||
|
||||
if (!canonicalKey || !visibleEntryKeySet.has(canonicalKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unique.includes(canonicalKey)) {
|
||||
unique.push(canonicalKey);
|
||||
}
|
||||
});
|
||||
|
||||
let resolvedAnchor: string | null = anchor ?? null;
|
||||
if (resolvedAnchor && !unique.includes(resolvedAnchor)) {
|
||||
resolvedAnchor = null;
|
||||
}
|
||||
|
||||
setSelectedEntries(unique);
|
||||
updateSelectionOrder(unique, interactedKeys);
|
||||
|
||||
const nextFocusedDocumentId: DocumentId | null = (() => {
|
||||
if (focusedDocumentId) {
|
||||
const focusKey = createDocumentEntryKey(focusedDocumentId);
|
||||
if (focusKey && unique.includes(focusKey)) {
|
||||
return focusedDocumentId;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedAnchor && isDocumentEntry(resolvedAnchor)) {
|
||||
return getEntryId(resolvedAnchor) ?? null;
|
||||
}
|
||||
|
||||
const lastDocKey = [...unique].reverse().find((key) => isDocumentEntry(key)) ?? null;
|
||||
return lastDocKey ? getEntryId(lastDocKey) ?? null : null;
|
||||
})();
|
||||
|
||||
setFocusedDocumentId(nextFocusedDocumentId);
|
||||
|
||||
if (resolvedAnchor) {
|
||||
selectionAnchorRef.current = resolvedAnchor;
|
||||
} else if (!unique.length) {
|
||||
selectionAnchorRef.current = null;
|
||||
} else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) {
|
||||
selectionAnchorRef.current = unique[unique.length - 1];
|
||||
}
|
||||
|
||||
return { selection: unique, focusKey: selectionAnchorRef.current };
|
||||
},
|
||||
[
|
||||
focusedDocumentId,
|
||||
updateSelectionOrder,
|
||||
],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setFocusedEntryKey(null);
|
||||
applySelection([], { anchor: null, interactedKeys: [] });
|
||||
}, [applySelection]);
|
||||
|
||||
const handleEntrySelection = useCallback(
|
||||
(entryKeyOrKeys: string | string[], event?: SelectionEventLike) => {
|
||||
const visibleEntryKeySet = visibleEntryKeySetRef.current;
|
||||
const navigableEntryKeys = navigableEntryKeysRef.current;
|
||||
|
||||
const entryKeys = Array.isArray(entryKeyOrKeys) ? entryKeyOrKeys : [entryKeyOrKeys];
|
||||
const validKeys = entryKeys.filter((key) => key && visibleEntryKeySet.has(key));
|
||||
|
||||
if (validKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the last valid key
|
||||
const lastKey = validKeys[validKeys.length - 1];
|
||||
setFocusedEntryKey(lastKey);
|
||||
|
||||
const shiftKey = Boolean(event?.shiftKey);
|
||||
const metaKey = Boolean(event?.metaKey);
|
||||
const ctrlKey = Boolean(event?.ctrlKey);
|
||||
const additive = metaKey || ctrlKey;
|
||||
|
||||
if (shiftKey) {
|
||||
event?.preventDefault?.();
|
||||
}
|
||||
|
||||
let anchorKey = selectionAnchorRef.current;
|
||||
if (!anchorKey && shiftKey && selectedEntries.length) {
|
||||
anchorKey = selectedEntries[selectedEntries.length - 1];
|
||||
}
|
||||
if (!anchorKey) {
|
||||
anchorKey = lastKey;
|
||||
}
|
||||
|
||||
let nextKeys: string[] = [];
|
||||
let interactedKeys: string[] = [];
|
||||
|
||||
// Shift selection logic (range) - primarily for single click + shift
|
||||
if (shiftKey && anchorKey && validKeys.length === 1) {
|
||||
const entryKey = validKeys[0];
|
||||
const anchorIndex = navigableEntryKeys.indexOf(anchorKey);
|
||||
const targetIndex = navigableEntryKeys.indexOf(entryKey);
|
||||
if (anchorIndex !== -1 && targetIndex !== -1) {
|
||||
const [start, end] = anchorIndex <= targetIndex
|
||||
? [anchorIndex, targetIndex]
|
||||
: [targetIndex, anchorIndex];
|
||||
const range = navigableEntryKeys.slice(start, end + 1);
|
||||
nextKeys = range;
|
||||
|
||||
const previousSet = new Set(selectedEntries);
|
||||
interactedKeys = range.filter((key) => key === entryKey || !previousSet.has(key));
|
||||
if (!interactedKeys.includes(entryKey)) {
|
||||
interactedKeys.push(entryKey);
|
||||
}
|
||||
} else {
|
||||
nextKeys = [entryKey];
|
||||
interactedKeys = [entryKey];
|
||||
}
|
||||
} else if (additive) {
|
||||
// Additive batch
|
||||
const previousSet = new Set(selectedEntries);
|
||||
if (validKeys.length === 1) {
|
||||
const entryKey = validKeys[0];
|
||||
if (previousSet.has(entryKey)) {
|
||||
nextKeys = selectedEntries.filter((key) => key !== entryKey);
|
||||
interactedKeys = [];
|
||||
} else {
|
||||
nextKeys = [...selectedEntries, entryKey];
|
||||
interactedKeys = [entryKey];
|
||||
}
|
||||
} else {
|
||||
// Batch add
|
||||
validKeys.forEach(key => previousSet.add(key));
|
||||
nextKeys = Array.from(previousSet) as string[];
|
||||
interactedKeys = validKeys;
|
||||
}
|
||||
anchorKey = lastKey;
|
||||
} else {
|
||||
// Replace with batch
|
||||
nextKeys = validKeys;
|
||||
interactedKeys = validKeys;
|
||||
anchorKey = lastKey;
|
||||
}
|
||||
|
||||
applySelection(nextKeys, { anchor: anchorKey, interactedKeys });
|
||||
},
|
||||
[applySelection, selectedEntries],
|
||||
);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId?: DocumentId | null) => {
|
||||
if (!docId) return;
|
||||
const entryKey = createDocumentEntryKey(docId);
|
||||
if (!entryKey) return;
|
||||
|
||||
if (!selectedEntries.includes(entryKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectionOrder(selectedEntries, [entryKey]);
|
||||
},
|
||||
[selectedEntries, updateSelectionOrder],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedEntries,
|
||||
setSelectedEntries,
|
||||
selectionOrder,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
selectionInitializedRef,
|
||||
focusedDocumentId,
|
||||
setFocusedDocumentId,
|
||||
focusedEntryKey,
|
||||
setFocusedEntryKey,
|
||||
applySelection,
|
||||
clearSelection,
|
||||
handleEntrySelection,
|
||||
promoteSelectionOrder,
|
||||
configureSelectionEnvironment,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
|
||||
type FolderId = DocumentId | 'root';
|
||||
|
||||
import type { Document } from '../types/documents';
|
||||
import useNotifyApiError from '../hooks/useNotifyApiError';
|
||||
|
||||
interface UseDocumentViewerArgs {
|
||||
routeDocumentId?: DocumentId | null;
|
||||
documentsManager: {
|
||||
getById: (id: DocumentId) => Document | null;
|
||||
ensure: (id: DocumentId) => Promise<Document | null>;
|
||||
getMany: (ids: DocumentId[]) => Document[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
|
||||
};
|
||||
selectedFolder?: FolderId | null;
|
||||
locationPathname: string;
|
||||
locationSearch: string;
|
||||
detailPanelControlRef: MutableRefObject<{
|
||||
open?: (args?: { documentIds?: DocumentId[] }) => void;
|
||||
close?: () => void;
|
||||
} | null>;
|
||||
setActiveViewerId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||
}
|
||||
|
||||
interface UseDocumentViewerResult {
|
||||
ensureViewerData: (documentId: DocumentId) => Promise<Document | null>;
|
||||
openDocumentViewer: (documentId: DocumentId, options?: { replace?: boolean }) => void;
|
||||
closeDocumentViewer: (folderId?: FolderId) => void;
|
||||
resetViewerState: () => void;
|
||||
viewerWorkspaceDocument: Document | null;
|
||||
viewerActive: boolean;
|
||||
}
|
||||
|
||||
const useDocumentViewer = ({
|
||||
routeDocumentId,
|
||||
documentsManager,
|
||||
selectedFolder,
|
||||
locationPathname,
|
||||
locationSearch,
|
||||
detailPanelControlRef,
|
||||
setActiveViewerId,
|
||||
}: UseDocumentViewerArgs): UseDocumentViewerResult => {
|
||||
const viewerReturnPathRef = useRef<string | null>(null);
|
||||
const notifyApiError = useNotifyApiError();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resetViewerState = useCallback(() => {
|
||||
viewerReturnPathRef.current = null;
|
||||
}, []);
|
||||
|
||||
const ensureViewerData = useCallback(
|
||||
async (documentId: DocumentId): Promise<Document | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const doc = await documentsManager.ensure(documentId);
|
||||
|
||||
if (!doc) {
|
||||
throw new Error('Document metadata unavailable.');
|
||||
}
|
||||
|
||||
if (!viewerReturnPathRef.current) {
|
||||
const fallbackFolderId = doc?.folder_id || 'root';
|
||||
viewerReturnPathRef.current =
|
||||
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
||||
}
|
||||
|
||||
setActiveViewerId(documentId);
|
||||
return doc;
|
||||
},
|
||||
[
|
||||
documentsManager,
|
||||
setActiveViewerId,
|
||||
],
|
||||
);
|
||||
|
||||
const openDocumentViewer = useCallback(
|
||||
(documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => {
|
||||
if (!documentId) return;
|
||||
detailPanelControlRef.current?.close?.();
|
||||
viewerReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||
navigate(`/documents/${documentId}`, { replace });
|
||||
},
|
||||
[navigate, locationPathname, locationSearch, detailPanelControlRef],
|
||||
);
|
||||
|
||||
const closeDocumentViewer = useCallback(
|
||||
(folderId?: FolderId) => {
|
||||
const fallbackPath = viewerReturnPathRef.current;
|
||||
viewerReturnPathRef.current = null;
|
||||
|
||||
if (fallbackPath) {
|
||||
navigate(fallbackPath, { replace: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = folderId || selectedFolder || 'root';
|
||||
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||
navigate(path, { replace: false });
|
||||
},
|
||||
[navigate, selectedFolder],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeDocumentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
ensureViewerData(routeDocumentId).catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Failed to open document preview.');
|
||||
closeDocumentViewer();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [routeDocumentId, ensureViewerData, notifyApiError, closeDocumentViewer]);
|
||||
|
||||
const viewerWorkspaceDocument = useMemo(() => {
|
||||
if (!routeDocumentId) {
|
||||
return null;
|
||||
}
|
||||
return documentsManager.getById(routeDocumentId);
|
||||
}, [routeDocumentId, documentsManager]);
|
||||
|
||||
const viewerActive = Boolean(routeDocumentId && viewerWorkspaceDocument);
|
||||
|
||||
return {
|
||||
ensureViewerData,
|
||||
openDocumentViewer,
|
||||
closeDocumentViewer,
|
||||
resetViewerState,
|
||||
viewerWorkspaceDocument,
|
||||
viewerActive,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentViewer;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_SORT_DIRECTION,
|
||||
DEFAULT_SORT_FIELD,
|
||||
SORT_FIELD_VALUES,
|
||||
} from './workspaceUtils';
|
||||
import {
|
||||
INCLUDE_DESCENDANTS_STORAGE_KEY,
|
||||
SORT_DIRECTION_STORAGE_KEY,
|
||||
SORT_FIELD_STORAGE_KEY,
|
||||
VIEW_MODE_STORAGE_KEY,
|
||||
} from '../constants/workspace';
|
||||
|
||||
const readSessionStorage = (key: string): string | null => {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.warn(`[session-storage] failed to read ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeSessionStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.warn(`[session-storage] failed to persist ${key}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useDocumentsPreferences = () => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
|
||||
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||
if (stored === 'grid' || stored === 'desk') {
|
||||
return stored;
|
||||
}
|
||||
return 'list';
|
||||
});
|
||||
|
||||
const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid');
|
||||
|
||||
const setDocumentsViewMode = useCallback((mode: string) => {
|
||||
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||
setDocumentsViewModeState((previous) => {
|
||||
if (next !== previous) {
|
||||
writeSessionStorage(VIEW_MODE_STORAGE_KEY, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeskExit = useCallback(() => {
|
||||
const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
|
||||
? lastNonDeskViewRef.current
|
||||
: 'list';
|
||||
setDocumentsViewMode(fallback);
|
||||
}, [setDocumentsViewMode]);
|
||||
|
||||
const [documentsSortField, setDocumentsSortField] = useState(() => {
|
||||
const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY);
|
||||
return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
|
||||
});
|
||||
useEffect(() => {
|
||||
writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField);
|
||||
}, [documentsSortField]);
|
||||
|
||||
const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
|
||||
const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY);
|
||||
return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||
}, [documentsSortDirection]);
|
||||
|
||||
const handleDocumentsSortFieldChange = useCallback((field: string) => {
|
||||
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||
}, []);
|
||||
|
||||
const handleDocumentsSortDirectionToggle = useCallback(() => {
|
||||
setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
|
||||
}, []);
|
||||
|
||||
const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
|
||||
const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
return true;
|
||||
});
|
||||
useEffect(() => {
|
||||
writeSessionStorage(
|
||||
INCLUDE_DESCENDANTS_STORAGE_KEY,
|
||||
searchIncludeDescendants ? 'true' : 'false',
|
||||
);
|
||||
}, [searchIncludeDescendants]);
|
||||
|
||||
const toggleSearchIncludeDescendants = useCallback(() => {
|
||||
setSearchIncludeDescendants((previous) => !previous);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documentsViewMode,
|
||||
handleDocumentsViewModeChange: setDocumentsViewMode,
|
||||
handleDeskExit,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
setSearchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppState } from '../lib/store/appState';
|
||||
import { TAG_FILTER_UNTAGGED } from './workspaceUtils';
|
||||
import { listDocuments } from '../lib/api/apiClient';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
import type { Document } from '../types/documents';
|
||||
|
||||
type ApiClient = {
|
||||
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
|
||||
};
|
||||
|
||||
import useNotifyApiError from '../hooks/useNotifyApiError';
|
||||
|
||||
interface UseDocumentsSearchArgs {
|
||||
api: ApiClient;
|
||||
selectedFolder?: Identifier | 'root' | null;
|
||||
locationPathname?: string;
|
||||
isDocumentsRoute?: boolean;
|
||||
searchIncludeDescendants?: boolean;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
setSearchIncludeDescendants: (value: boolean) => void;
|
||||
documentsManager: {
|
||||
ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
interface UseDocumentsSearchResult {
|
||||
searchQuery: string;
|
||||
setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||
searchResultIds: Identifier[] | null;
|
||||
setSearchResultIds: Dispatch<SetStateAction<Identifier[] | 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;
|
||||
refetchSearchResults: () => void;
|
||||
documentsFilterValue: {
|
||||
query: string;
|
||||
searchResultIds: Identifier[] | null;
|
||||
searchLoading: boolean;
|
||||
includeDescendants: boolean;
|
||||
activeTagIds: Identifier[];
|
||||
activeCorrespondentIds: Identifier[];
|
||||
isActive: boolean;
|
||||
setQuery: (value: string) => void;
|
||||
submit: () => void;
|
||||
clear: () => void;
|
||||
toggleTag: (tagId: Identifier) => void;
|
||||
toggleCorrespondent: (correspondentId?: Identifier | null) => void;
|
||||
toggleIncludeDescendants: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const useDocumentsSearch = ({
|
||||
api,
|
||||
selectedFolder,
|
||||
locationPathname,
|
||||
isDocumentsRoute,
|
||||
searchIncludeDescendants,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
setSearchIncludeDescendants,
|
||||
documentsManager,
|
||||
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
|
||||
const { token } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
|
||||
const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null);
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchTrigger, setSearchTrigger] = useState<number>(0);
|
||||
const notifyApiError = useNotifyApiError();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleTagFilter = useCallback((tagId: Identifier) => {
|
||||
if (!tagId) return;
|
||||
setActiveTagFilters((previous) => {
|
||||
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||
return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
|
||||
}
|
||||
const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||
if (sanitized.includes(tagId)) {
|
||||
return sanitized.filter((id) => id !== tagId);
|
||||
}
|
||||
return sanitized.concat([tagId]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
|
||||
setActiveCorrespondentFilters((previous) => {
|
||||
if (!correspondentId) {
|
||||
return [];
|
||||
}
|
||||
return previous.includes(correspondentId) ? [] : [correspondentId];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFilterActive = useMemo(
|
||||
() =>
|
||||
searchQuery.trim().length > 0
|
||||
|| activeTagFilters.length > 0
|
||||
|| activeCorrespondentFilters.length > 0,
|
||||
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
setActiveTagFilters([]);
|
||||
setActiveCorrespondentFilters([]);
|
||||
setSearchLoading(false);
|
||||
setSearchIncludeDescendants(true);
|
||||
setSearchResultIds(null);
|
||||
}, [
|
||||
setSearchIncludeDescendants,
|
||||
]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (!navigate) return;
|
||||
const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
|
||||
const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
|
||||
if (!isDocumentsRoute || locationPathname !== targetPath) {
|
||||
navigate(targetPath, { replace: false });
|
||||
}
|
||||
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
|
||||
|
||||
const documentsFilterValue = useMemo(
|
||||
() => ({
|
||||
query: searchQuery,
|
||||
searchResultIds,
|
||||
searchLoading,
|
||||
includeDescendants: Boolean(searchIncludeDescendants),
|
||||
activeTagIds: activeTagFilters,
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
isActive: isFilterActive,
|
||||
setQuery: handleSearchChange,
|
||||
submit: handleSearchSubmit,
|
||||
clear: clearFilters,
|
||||
toggleTag: toggleTagFilter,
|
||||
toggleCorrespondent: toggleCorrespondentFilter,
|
||||
toggleIncludeDescendants: () => setSearchIncludeDescendants(!searchIncludeDescendants),
|
||||
}),
|
||||
[
|
||||
searchQuery,
|
||||
searchResultIds,
|
||||
searchLoading,
|
||||
searchIncludeDescendants,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
isFilterActive,
|
||||
handleSearchChange,
|
||||
handleSearchSubmit,
|
||||
clearFilters,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
setSearchIncludeDescendants,
|
||||
],
|
||||
);
|
||||
|
||||
const refetchSearchResults = useCallback(() => {
|
||||
setSearchTrigger(Date.now());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
|
||||
if (!isFilterActive) {
|
||||
setSearchResultIds(null);
|
||||
setSearchLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
setSearchLoading(true);
|
||||
|
||||
const debounce = setTimeout(async () => {
|
||||
started = true;
|
||||
try {
|
||||
const params: Record<string, unknown> = {};
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
if (trimmedQuery.length) {
|
||||
params.query = trimmedQuery;
|
||||
}
|
||||
if (activeTagFilters.length) {
|
||||
const onlyUntagged = activeTagFilters.length === 1
|
||||
&& activeTagFilters[0] === TAG_FILTER_UNTAGGED;
|
||||
if (onlyUntagged) {
|
||||
params.tags = 'none';
|
||||
} else {
|
||||
const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||
if (tagIds.length) {
|
||||
params.tags = tagIds.join(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activeCorrespondentFilters.length) {
|
||||
params.correspondents = activeCorrespondentFilters.join(',');
|
||||
}
|
||||
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
||||
if (folderIdentifier) {
|
||||
params.folder_id = folderIdentifier;
|
||||
}
|
||||
if (!searchIncludeDescendants) {
|
||||
params.include_descendants = false;
|
||||
}
|
||||
if (documentsSortField) {
|
||||
params.sort = documentsSortField;
|
||||
}
|
||||
if (documentsSortDirection) {
|
||||
params.dir = documentsSortDirection;
|
||||
}
|
||||
const data = await listDocuments(params);
|
||||
if (cancelled) return;
|
||||
|
||||
const results = Array.isArray(data) ? data : [];
|
||||
const { canonical } = documentsManager.ingest(results);
|
||||
const ids = canonical
|
||||
.map((doc) => (doc?.id ?? null) as Identifier | null)
|
||||
.filter((id): id is Identifier => id != null);
|
||||
setSearchResultIds(ids);
|
||||
|
||||
if (!ids.length) {
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
notifyApiError(error, 'Search failed. Please try again.');
|
||||
setSearchResultIds(null);
|
||||
} finally {
|
||||
if (!cancelled && started) {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
if (started) {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
api,
|
||||
token,
|
||||
isFilterActive,
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
searchIncludeDescendants,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
selectedFolder,
|
||||
notifyApiError,
|
||||
documentsManager,
|
||||
searchTrigger,
|
||||
]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResultIds,
|
||||
setSearchResultIds,
|
||||
searchLoading,
|
||||
setSearchLoading,
|
||||
activeTagFilters,
|
||||
setActiveTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
setActiveCorrespondentFilters,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
isFilterActive,
|
||||
clearFilters,
|
||||
handleSearchChange,
|
||||
handleSearchSubmit,
|
||||
refetchSearchResults,
|
||||
documentsFilterValue,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentsSearch;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppShell } from '../lib/context/AppShellContext';
|
||||
import type { DocumentsFilterValue } from '../documents/context/DocumentsFilterContext';
|
||||
import type { UseWorkspaceSurfaceArgs } from './useWorkspaceSurface';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
type WorkspaceSurfaceConfig = Omit<UseWorkspaceSurfaceArgs, 'sidebarHidden' | 'onExpandSidebar'> & {
|
||||
openDetailPanel?: (documentId: Identifier) => void;
|
||||
closeDetailPanel?: () => void;
|
||||
handleBreadcrumbNavigate?: (crumb: any) => void;
|
||||
};
|
||||
|
||||
interface DocumentsShellView {
|
||||
surfaceConfig: WorkspaceSurfaceConfig;
|
||||
documentsFilter: DocumentsFilterValue;
|
||||
documentsManager: any;
|
||||
foldersManager: any;
|
||||
}
|
||||
|
||||
const useDocumentsShell = (): DocumentsShellView => {
|
||||
const shell = useAppShell() as any;
|
||||
|
||||
return useMemo(() => {
|
||||
const surfaceConfig: WorkspaceSurfaceConfig = {
|
||||
viewMode: shell.search?.documentsViewMode,
|
||||
detailPanelProps: (shell.detailPanel?.detailPanelProps ?? null) as WorkspaceSurfaceConfig['detailPanelProps'],
|
||||
detailPanelOpen: Boolean(shell.detailPanel?.detailPanelOpen),
|
||||
openDetailPanel: shell.detailPanel?.openDetailPanel as WorkspaceSurfaceConfig['openDetailPanel'],
|
||||
closeDetailPanel: shell.detailPanel?.closeDetailPanel as WorkspaceSurfaceConfig['closeDetailPanel'],
|
||||
viewerWorkspaceDocument: shell.preview?.viewerWorkspaceDocument,
|
||||
viewerDocumentId: (shell.preview?.viewerDocumentId as Identifier) ?? null,
|
||||
closeDocumentViewer: shell.preview?.closeDocumentViewer as WorkspaceSurfaceConfig['closeDocumentViewer'],
|
||||
ensureViewerData: shell.preview?.ensureViewerData as WorkspaceSurfaceConfig['ensureViewerData'],
|
||||
ensureAssetUrl: shell.preview?.ensureAssetUrl as WorkspaceSurfaceConfig['ensureAssetUrl'],
|
||||
getDocumentAsset: shell.preview?.getDocumentAsset as WorkspaceSurfaceConfig['getDocumentAsset'],
|
||||
notifyApiError: shell.ui?.notifyApiError as WorkspaceSurfaceConfig['notifyApiError'],
|
||||
handleBreadcrumbNavigate: shell.folderTree?.handleBreadcrumbNavigate as WorkspaceSurfaceConfig['handleBreadcrumbNavigate'],
|
||||
};
|
||||
|
||||
return {
|
||||
surfaceConfig,
|
||||
documentsFilter: shell.search?.documentsFilter as DocumentsFilterValue,
|
||||
documentsManager: shell.managers?.documentsManager,
|
||||
foldersManager: shell.folderTree?.foldersManager,
|
||||
};
|
||||
}, [shell]);
|
||||
};
|
||||
|
||||
export default useDocumentsShell;
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useStatusToast } from '../lib/context/StatusToastContext';
|
||||
import TagsPanel from '../tags/TagsPanel';
|
||||
import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel';
|
||||
import PanelHeader from '../components/PanelHeader';
|
||||
import { CloseIcon } from '../components/icons';
|
||||
import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app';
|
||||
import type { Tag, Correspondent } from '../types/documents';
|
||||
import type CorrespondentManager from '../lib/assets/CorrespondentManager';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
interface UseManagementModalsArgs {
|
||||
locationPathname?: string;
|
||||
tags?: Tag[];
|
||||
refreshTags?: () => void | Promise<void>;
|
||||
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
||||
correspondents?: Correspondent[];
|
||||
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||
correspondentLookupByName?: Map<string, Correspondent> | null;
|
||||
refreshCorrespondents?: () => void | Promise<void>;
|
||||
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
||||
correspondentManager?: CorrespondentManager | null;
|
||||
}
|
||||
|
||||
interface UseManagementModalsResult {
|
||||
managementModals: ReactNode;
|
||||
openTagsModal: () => void;
|
||||
openCorrespondentsModal: () => void;
|
||||
closeActiveModal: () => void;
|
||||
activeModal: string | null;
|
||||
}
|
||||
|
||||
export const useManagementModals = ({
|
||||
locationPathname,
|
||||
tags = [],
|
||||
refreshTags,
|
||||
onTagCreate,
|
||||
onTagUpdate,
|
||||
onTagDelete,
|
||||
correspondents = [],
|
||||
refreshCorrespondents,
|
||||
onCorrespondentCreate,
|
||||
onCorrespondentUpdate,
|
||||
onCorrespondentDelete,
|
||||
}: UseManagementModalsArgs): UseManagementModalsResult => {
|
||||
const { showToast } = useStatusToast();
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
|
||||
const openCorrespondentsModal = useCallback(
|
||||
() => setActiveModal(CORRESPONDENTS_MODAL),
|
||||
[],
|
||||
);
|
||||
const closeActiveModal = useCallback(() => setActiveModal(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveModal(null);
|
||||
}, [locationPathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveModal(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeModal]);
|
||||
|
||||
const tagModal = useMemo(() => {
|
||||
if (activeModal !== TAGS_MODAL) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={closeActiveModal}
|
||||
>
|
||||
<div
|
||||
className="modal modal--panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tags-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PanelHeader
|
||||
className="panel-modal__header"
|
||||
title="Manage Tags"
|
||||
titleTag="h3"
|
||||
titleProps={{ id: 'tags-modal-title' }}
|
||||
actions={(
|
||||
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<div className="panel-modal__body">
|
||||
<TagsPanel
|
||||
tags={tags}
|
||||
onRefresh={refreshTags}
|
||||
onCreateTag={onTagCreate}
|
||||
onUpdateTag={onTagUpdate}
|
||||
onDeleteTag={onTagDelete}
|
||||
onNotify={showToast}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
activeModal,
|
||||
closeActiveModal,
|
||||
onTagCreate,
|
||||
onTagDelete,
|
||||
onTagUpdate,
|
||||
refreshTags,
|
||||
showToast,
|
||||
tags,
|
||||
]);
|
||||
|
||||
const handleCorrespondentCreateSafe = useCallback<CorrespondentsPanelProps['onCreate']>(
|
||||
async (payload) => {
|
||||
if (!onCorrespondentCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return onCorrespondentCreate(payload) ?? undefined;
|
||||
},
|
||||
[onCorrespondentCreate],
|
||||
);
|
||||
|
||||
const handleCorrespondentUpdateSafe = useCallback<CorrespondentsPanelProps['onUpdate']>(
|
||||
async (id, payload) => {
|
||||
if (!onCorrespondentUpdate) {
|
||||
return;
|
||||
}
|
||||
await onCorrespondentUpdate(id, payload);
|
||||
},
|
||||
[onCorrespondentUpdate],
|
||||
);
|
||||
|
||||
const handleCorrespondentDeleteSafe = useCallback<CorrespondentsPanelProps['onDelete']>(
|
||||
async (id) => {
|
||||
if (!onCorrespondentDelete) {
|
||||
return;
|
||||
}
|
||||
await onCorrespondentDelete(id);
|
||||
},
|
||||
[onCorrespondentDelete],
|
||||
);
|
||||
|
||||
const correspondentsModal = useMemo(() => {
|
||||
if (activeModal !== CORRESPONDENTS_MODAL) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={closeActiveModal}
|
||||
>
|
||||
<div
|
||||
className="modal modal--panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="correspondents-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PanelHeader
|
||||
className="panel-modal__header"
|
||||
title="Manage Correspondents"
|
||||
titleTag="h3"
|
||||
titleProps={{ id: 'correspondents-modal-title' }}
|
||||
actions={(
|
||||
<button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close">
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<div className="panel-modal__body">
|
||||
<CorrespondentsPanel
|
||||
correspondents={correspondents}
|
||||
onRefresh={refreshCorrespondents}
|
||||
onCreate={handleCorrespondentCreateSafe}
|
||||
onUpdate={handleCorrespondentUpdateSafe}
|
||||
onDelete={handleCorrespondentDeleteSafe}
|
||||
onNotify={showToast}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
activeModal,
|
||||
closeActiveModal,
|
||||
correspondents,
|
||||
handleCorrespondentCreateSafe,
|
||||
handleCorrespondentDeleteSafe,
|
||||
handleCorrespondentUpdateSafe,
|
||||
refreshCorrespondents,
|
||||
showToast,
|
||||
]);
|
||||
|
||||
const managementModals = (
|
||||
<>
|
||||
{tagModal}
|
||||
{correspondentsModal}
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
managementModals,
|
||||
openTagsModal,
|
||||
openCorrespondentsModal,
|
||||
closeActiveModal,
|
||||
activeModal,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDocumentSelection } from './useDocumentSelection';
|
||||
import { isDocumentEntry, isFolderEntry, getEntryId } from './entryKey';
|
||||
|
||||
interface SelectionEntry {
|
||||
entryKey?: string;
|
||||
// Legacy field for compatibility
|
||||
// rowKey?: string; // Removed as part of refactor
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface WorkspaceSelectionOptions {
|
||||
onDocumentActivate?: (id: string) => void;
|
||||
onInspectFolder?: (id: string) => void;
|
||||
}
|
||||
|
||||
const identity = <T,>(value: T) => value;
|
||||
|
||||
export const useWorkspaceSelection = ({
|
||||
onDocumentActivate = identity,
|
||||
onInspectFolder = identity,
|
||||
}: WorkspaceSelectionOptions = {}) => {
|
||||
const selection = useDocumentSelection();
|
||||
|
||||
const {
|
||||
selectedEntries,
|
||||
setSelectedEntries,
|
||||
selectionOrder,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
selectionInitializedRef,
|
||||
focusedDocumentId,
|
||||
setFocusedDocumentId,
|
||||
focusedEntryKey,
|
||||
setFocusedEntryKey,
|
||||
applySelection,
|
||||
clearSelection,
|
||||
handleEntrySelection,
|
||||
promoteSelectionOrder,
|
||||
configureSelectionEnvironment,
|
||||
} = selection;
|
||||
|
||||
const selectedDocumentIds = useMemo(
|
||||
() =>
|
||||
selectedEntries
|
||||
.filter((entry) => isDocumentEntry(entry))
|
||||
.map((entry) => getEntryId(entry))
|
||||
.filter(Boolean),
|
||||
[selectedEntries],
|
||||
);
|
||||
|
||||
const selectedFolderIds = useMemo(
|
||||
() =>
|
||||
selectedEntries
|
||||
.filter((entry) => isFolderEntry(entry))
|
||||
.map((entry) => getEntryId(entry))
|
||||
.filter(Boolean),
|
||||
[selectedEntries],
|
||||
);
|
||||
|
||||
const selectEntry = useCallback(
|
||||
(entryOrEntries: SelectionEntry | string | Array<SelectionEntry | string>, event?: unknown) => {
|
||||
const entries = Array.isArray(entryOrEntries) ? entryOrEntries : [entryOrEntries];
|
||||
const entryKeys = entries
|
||||
.map((entry) => {
|
||||
return entry && Object(entry) === entry
|
||||
? (entry as SelectionEntry).entryKey ?? undefined
|
||||
: (entry as string);
|
||||
})
|
||||
.filter((key): key is string => Boolean(key));
|
||||
|
||||
if (entryKeys.length === 0) return;
|
||||
handleEntrySelection(entryKeys, event);
|
||||
},
|
||||
[handleEntrySelection],
|
||||
);
|
||||
|
||||
const inspectDocument = useCallback(
|
||||
(documentId?: string) => {
|
||||
if (!documentId) return;
|
||||
onDocumentActivate(documentId);
|
||||
},
|
||||
[onDocumentActivate],
|
||||
);
|
||||
|
||||
const inspectFolder = useCallback(
|
||||
(folderId?: string) => {
|
||||
if (!folderId) return;
|
||||
onInspectFolder(folderId);
|
||||
},
|
||||
[onInspectFolder],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
selectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
selectionInitializedRef,
|
||||
focusedDocumentId,
|
||||
setFocusedDocumentId,
|
||||
focusedEntryKey,
|
||||
setFocusedEntryKey,
|
||||
applySelection,
|
||||
clearSelection,
|
||||
handleEntrySelection: selectEntry,
|
||||
promoteSelectionOrder,
|
||||
configureSelectionEnvironment,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
inspectDocument,
|
||||
inspectFolder,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { SidebarExpandIcon } from '../components/icons';
|
||||
import DocumentsPanel from '../documents/panel/DocumentsPanel';
|
||||
import DocumentViewerPanel from '../viewer/DocumentViewerPanel';
|
||||
import { usePanelManager } from './PanelManagerContext';
|
||||
import { FolderManagerProvider } from '../folders/FolderManagerContext';
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
docId: Identifier,
|
||||
asset: unknown,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<unknown> | void;
|
||||
type EnsureViewerData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
type DetailPanelProps = (ComponentProps<typeof DocumentViewerPanel> & {
|
||||
onClose?: () => void;
|
||||
onOpenViewer?: (args: { documentIds: Array<string> }) => void;
|
||||
folderNodes?: Map<Identifier | 'root', unknown>;
|
||||
ensureFolderData?: (
|
||||
folderId: Identifier | 'root',
|
||||
options?: { force?: boolean; includeDocuments?: boolean },
|
||||
) => Promise<void>;
|
||||
}) | null;
|
||||
|
||||
type WorkspaceSurface = { content: ReactNode; detail?: ReactNode | null; detailMode?: 'overlay' | 'inline' | null } | null;
|
||||
|
||||
export interface UseWorkspaceSurfaceArgs {
|
||||
sidebarHidden?: boolean;
|
||||
onExpandSidebar?: () => void;
|
||||
viewMode?: string;
|
||||
detailPanelProps?: DetailPanelProps;
|
||||
detailPanelOpen?: boolean;
|
||||
viewerWorkspaceDocument?: unknown;
|
||||
viewerDocumentId?: Identifier | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
ensureViewerData?: EnsureViewerData;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
notifyApiError?: NotifyApiError;
|
||||
closeDocumentViewer?: () => void;
|
||||
}
|
||||
|
||||
interface UseWorkspaceSurfaceResult {
|
||||
surface: WorkspaceSurface;
|
||||
}
|
||||
|
||||
export const useWorkspaceSurface = ({
|
||||
sidebarHidden = false,
|
||||
onExpandSidebar,
|
||||
viewMode,
|
||||
detailPanelProps,
|
||||
detailPanelOpen = false,
|
||||
viewerWorkspaceDocument,
|
||||
viewerDocumentId,
|
||||
ensureAssetUrl,
|
||||
ensureViewerData,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
closeDocumentViewer,
|
||||
}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
|
||||
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = detailPanelProps?.onClose || null;
|
||||
registerDetailCloseHandler(handler);
|
||||
return () => registerDetailCloseHandler(null);
|
||||
}, [registerDetailCloseHandler, detailPanelProps?.onClose]);
|
||||
|
||||
const setDetailActiveRef = useRef(setDetailActive);
|
||||
useEffect(() => {
|
||||
setDetailActiveRef.current = setDetailActive;
|
||||
}, [setDetailActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailActive(Boolean(detailPanelOpen));
|
||||
}, [detailPanelOpen, setDetailActive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (setDetailActiveRef.current) {
|
||||
setDetailActiveRef.current(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderSidebarToggle = useCallback<() => ReactNode>(() => {
|
||||
if (!sidebarHidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onExpandSidebar}
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<SidebarExpandIcon />
|
||||
</button>
|
||||
);
|
||||
}, [sidebarHidden, onExpandSidebar]);
|
||||
|
||||
const documentsSurface = useMemo<WorkspaceSurface>(() => {
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const sidebarMode = viewMode === 'desk' ? 'overlay' : 'inline';
|
||||
|
||||
const detailMode: 'overlay' | 'inline' | null = detailPanelOpen && detailPanelProps ? sidebarMode : null;
|
||||
const detail = detailPanelOpen && detailPanelProps
|
||||
? (() => {
|
||||
const {
|
||||
onClose,
|
||||
onOpenViewer,
|
||||
tags: tagOptions,
|
||||
folderNodes,
|
||||
ensureFolderData,
|
||||
...restDetailProps
|
||||
} = detailPanelProps;
|
||||
const viewer = (
|
||||
<DocumentViewerPanel
|
||||
variant="sidebar"
|
||||
sidebarMode={sidebarMode}
|
||||
onClose={onClose}
|
||||
onMaximize={onOpenViewer}
|
||||
tagOptions={tagOptions}
|
||||
{...restDetailProps}
|
||||
/>
|
||||
);
|
||||
if (folderNodes && ensureFolderData) {
|
||||
return (
|
||||
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
|
||||
{viewer}
|
||||
</FolderManagerProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>{viewer}</>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: (
|
||||
<DocumentsPanel
|
||||
headerLeading={sidebarToggle}
|
||||
/>
|
||||
),
|
||||
detail,
|
||||
detailMode,
|
||||
};
|
||||
}, [
|
||||
viewMode,
|
||||
renderSidebarToggle,
|
||||
detailPanelOpen,
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const showViewerWorkspace = Boolean(viewerDocumentId);
|
||||
|
||||
const viewerSurface = useMemo<WorkspaceSurface>(() => {
|
||||
if (!showViewerWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const detailExtras = detailPanelProps || {};
|
||||
const {
|
||||
tagLookupById,
|
||||
tags: tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
correspondentLookupById,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
folderNodes,
|
||||
ensureFolderData,
|
||||
} = detailExtras;
|
||||
|
||||
const viewer = (
|
||||
<DocumentViewerPanel
|
||||
document={viewerWorkspaceDocument || null}
|
||||
hydrateDocument={ensureViewerData}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tagOptions}
|
||||
onTagAdd={onTagAdd}
|
||||
onTagRemove={onTagRemove}
|
||||
correspondents={correspondents}
|
||||
correspondentLookupById={correspondentLookupById}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
ensurePreviewData={ensureViewerData}
|
||||
notifyApiError={notifyApiError}
|
||||
sidebarToggle={sidebarToggle}
|
||||
onClose={closeDocumentViewer}
|
||||
resolveFolderPath={resolveFolderPath}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = folderNodes && ensureFolderData
|
||||
? (
|
||||
<FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}>
|
||||
{viewer}
|
||||
</FolderManagerProvider>
|
||||
)
|
||||
: viewer;
|
||||
|
||||
return { content, detail: null, detailMode: null };
|
||||
}, [
|
||||
showViewerWorkspace,
|
||||
viewerWorkspaceDocument,
|
||||
ensureViewerData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
renderSidebarToggle,
|
||||
closeDocumentViewer,
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const surface = useMemo<WorkspaceSurface>(() => {
|
||||
if (showViewerWorkspace) {
|
||||
return viewerSurface;
|
||||
}
|
||||
return documentsSurface;
|
||||
}, [showViewerWorkspace, viewerSurface, documentsSurface]);
|
||||
|
||||
return { surface };
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FolderTreeNode } from '../lib/api/apiTypes';
|
||||
import {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
DEFAULT_SORT_DIRECTION,
|
||||
DEFAULT_SORT_FIELD,
|
||||
SORT_FIELD_VALUES,
|
||||
TAG_FILTER_UNTAGGED,
|
||||
} from '../constants/workspace';
|
||||
|
||||
export {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
DEFAULT_SORT_DIRECTION,
|
||||
DEFAULT_SORT_FIELD,
|
||||
SORT_FIELD_VALUES,
|
||||
TAG_FILTER_UNTAGGED,
|
||||
};
|
||||
|
||||
export const hasFiles = (event) =>
|
||||
Array.from(event.dataTransfer?.types || []).includes('Files');
|
||||
|
||||
const mergeAssetIntoGroup = (group, assetData) => {
|
||||
if (!assetData || !assetData.asset_type) {
|
||||
return group || [];
|
||||
}
|
||||
|
||||
const list = Array.isArray(group) ? group : [];
|
||||
const index = list.findIndex((item) => item?.asset_type === assetData.asset_type);
|
||||
if (index >= 0) {
|
||||
const next = list.slice();
|
||||
next[index] = assetData;
|
||||
return next;
|
||||
}
|
||||
return list.concat(assetData);
|
||||
};
|
||||
|
||||
export const mergeAssetIntoDocument = (doc, assetData) => {
|
||||
if (!doc) return doc;
|
||||
const nextGroup = mergeAssetIntoGroup(doc.current_version?.assets, assetData);
|
||||
return {
|
||||
...doc,
|
||||
current_version: { ...(doc.current_version || {}), assets: nextGroup },
|
||||
};
|
||||
};
|
||||
|
||||
export const createRootNode = () => ({
|
||||
id: 'root',
|
||||
name: DEFAULT_FOLDER_NAME,
|
||||
parentId: null,
|
||||
children: [],
|
||||
expanded: true,
|
||||
loaded: false,
|
||||
hasChildren: false,
|
||||
});
|
||||
|
||||
export const flattenFolderTree = (data: FolderTreeNode[]): FolderTreeNode[] => {
|
||||
const result: FolderTreeNode[] = [];
|
||||
data.forEach((item) => {
|
||||
result.push(item);
|
||||
if (item.children) {
|
||||
result.push(...flattenFolderTree(item.children));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<linearGradient id="folderGradient" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||
<stop offset="0" stop-color="var(--folder-icon-back, #62a0ea)"/>
|
||||
<stop offset="0.5" stop-color="var(--folder-icon-mid, #afd4ff)"/>
|
||||
<stop offset="1" stop-color="var(--folder-icon-front, #62a0ea)"/>
|
||||
</linearGradient>
|
||||
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="var(--folder-icon-back, #438de6)"/>
|
||||
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#folderGradient)"/>
|
||||
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="var(--folder-icon-front, #a4caee)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<defs>
|
||||
<filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%">
|
||||
<feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/>
|
||||
</filter>
|
||||
<linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f3f3f3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g filter="url(#cornerShadow)">
|
||||
<g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)">
|
||||
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)">
|
||||
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,421 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import useFloatingMenu from './useFloatingMenu';
|
||||
import {
|
||||
ELLIPSIS,
|
||||
WIDTH_BUFFER_RATIO,
|
||||
WIDTH_CHANGE_TOLERANCE,
|
||||
WIDTH_TOLERANCE,
|
||||
} from '../constants/ui';
|
||||
|
||||
const normalizeEntries = (entries) =>
|
||||
(Array.isArray(entries) ? entries : [])
|
||||
.map((entry, index) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const id = entry.id ?? entry.value ?? index;
|
||||
const label = entry.label ?? entry.name ?? entry.title ?? '';
|
||||
const onClick = entry.onClick ? entry.onClick : null;
|
||||
return label ? { id, label, onClick, raw: entry } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const BreadcrumbTrail = ({
|
||||
entries = [],
|
||||
className = '',
|
||||
separator = '/',
|
||||
truncateFromStart = true,
|
||||
}) => {
|
||||
const normalized = useMemo(() => normalizeEntries(entries), [entries]);
|
||||
const shouldTruncateFromStart = truncateFromStart !== false;
|
||||
const measurementEntries = useMemo(
|
||||
() => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()),
|
||||
[normalized, shouldTruncateFromStart],
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measurementRef = useRef<HTMLDivElement | null>(null);
|
||||
const ellipsisButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [availableWidth, setAvailableWidth] = useState(null);
|
||||
const [startIndex, setStartIndex] = useState(0);
|
||||
const measureRafRef = useRef(null);
|
||||
|
||||
const {
|
||||
isOpen: ellipsisMenuOpen,
|
||||
toggle: toggleEllipsisMenu,
|
||||
close: closeEllipsisMenu,
|
||||
menuRef: ellipsisMenuRef,
|
||||
menuStyle: ellipsisMenuStyle,
|
||||
updatePosition: refreshEllipsisMenuPosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef: ellipsisButtonRef,
|
||||
minWidth: 192,
|
||||
offset: 6,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
closeEllipsisMenu();
|
||||
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveHost = () => containerRef.current?.parentElement || containerRef.current;
|
||||
const measure = () => {
|
||||
const host = resolveHost();
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const nextWidth = host.getBoundingClientRect().width;
|
||||
if (!nextWidth) {
|
||||
return;
|
||||
}
|
||||
setAvailableWidth((prev) => (
|
||||
prev && Math.abs(prev - nextWidth) < WIDTH_CHANGE_TOLERANCE ? prev : nextWidth
|
||||
));
|
||||
};
|
||||
|
||||
const scheduleMeasure = () => {
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (!raf) {
|
||||
measure();
|
||||
return;
|
||||
}
|
||||
if (measureRafRef.current) {
|
||||
cancelAnimationFrame(measureRafRef.current);
|
||||
}
|
||||
measureRafRef.current = raf(() => {
|
||||
measureRafRef.current = null;
|
||||
measure();
|
||||
});
|
||||
};
|
||||
|
||||
scheduleMeasure();
|
||||
|
||||
if (!('ResizeObserver' in window)) {
|
||||
return () => {
|
||||
if (measureRafRef.current) {
|
||||
cancelAnimationFrame(measureRafRef.current);
|
||||
measureRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const host = resolveHost();
|
||||
if (!host) {
|
||||
return () => {
|
||||
if (measureRafRef.current) {
|
||||
cancelAnimationFrame(measureRafRef.current);
|
||||
measureRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(scheduleMeasure);
|
||||
observer.observe(host);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (measureRafRef.current) {
|
||||
cancelAnimationFrame(measureRafRef.current);
|
||||
measureRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!measurementEntries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const measurement = measurementRef.current;
|
||||
const host = container?.parentElement || container;
|
||||
if (!container || !measurement || !host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryNodes = Array.from(
|
||||
measurement.querySelectorAll('[data-item-type="entry"]'),
|
||||
) as HTMLElement[];
|
||||
if (!entryNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separatorNodes = Array.from(
|
||||
measurement.querySelectorAll('[data-item-type="separator"]'),
|
||||
) as HTMLElement[];
|
||||
const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]') as HTMLElement | null;
|
||||
|
||||
const originalEntryDisplay = entryNodes.map((node) => node.style.display);
|
||||
const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display);
|
||||
const originalEllipsisDisplay = ellipsisNode ? ellipsisNode.style.display : null;
|
||||
|
||||
const widths = [];
|
||||
|
||||
for (let start = 0; start < entryNodes.length; start += 1) {
|
||||
entryNodes.forEach((node, index) => {
|
||||
// Hide entries that fall before the visible window.
|
||||
node.style.display = index < start ? 'none' : '';
|
||||
});
|
||||
|
||||
separatorNodes.forEach((node) => {
|
||||
const targetIndex = Number(node.getAttribute('data-target-index'));
|
||||
node.style.display = targetIndex < Math.max(start, 1) ? 'none' : '';
|
||||
});
|
||||
|
||||
if (ellipsisNode) {
|
||||
ellipsisNode.style.display = start > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
widths[start] = measurement.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
entryNodes.forEach((node, index) => {
|
||||
node.style.display = originalEntryDisplay[index] ?? '';
|
||||
});
|
||||
|
||||
separatorNodes.forEach((node, index) => {
|
||||
node.style.display = originalSeparatorDisplay[index] ?? '';
|
||||
});
|
||||
|
||||
if (ellipsisNode) {
|
||||
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
||||
}
|
||||
|
||||
const available = availableWidth ?? host.getBoundingClientRect().width;
|
||||
if (!available || !widths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve a tiny buffer so the live trail doesn't oscillate when the
|
||||
// container width barely fits; shrink the measured allowance a bit.
|
||||
const adjustedAvailable = available * WIDTH_BUFFER_RATIO;
|
||||
|
||||
let nextStart = widths.length - 1;
|
||||
for (let start = 0; start < widths.length; start += 1) {
|
||||
if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) {
|
||||
nextStart = start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStart !== startIndex) {
|
||||
setStartIndex(nextStart);
|
||||
}
|
||||
}, [measurementEntries, separator, availableWidth, startIndex]);
|
||||
|
||||
const trimmedCount = Math.min(startIndex, Math.max(0, normalized.length - 1));
|
||||
const visibleEntries = shouldTruncateFromStart
|
||||
? normalized.slice(trimmedCount)
|
||||
: normalized.slice(0, Math.max(normalized.length - trimmedCount, 1));
|
||||
const hiddenEntries = trimmedCount === 0
|
||||
? []
|
||||
: shouldTruncateFromStart
|
||||
? normalized.slice(0, trimmedCount)
|
||||
: normalized.slice(-trimmedCount);
|
||||
const hasHiddenEntries = hiddenEntries.length > 0;
|
||||
const ellipsisPlacement = shouldTruncateFromStart ? 'start' : 'end';
|
||||
const displayEntries = hasHiddenEntries
|
||||
? ellipsisPlacement === 'start'
|
||||
? [ELLIPSIS, ...visibleEntries]
|
||||
: [...visibleEntries, ELLIPSIS]
|
||||
: visibleEntries;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHiddenEntries) {
|
||||
closeEllipsisMenu();
|
||||
return;
|
||||
}
|
||||
if (ellipsisMenuOpen) {
|
||||
refreshEllipsisMenuPosition();
|
||||
}
|
||||
}, [
|
||||
hasHiddenEntries,
|
||||
ellipsisMenuOpen,
|
||||
closeEllipsisMenu,
|
||||
refreshEllipsisMenuPosition,
|
||||
hiddenEntries.length,
|
||||
]);
|
||||
|
||||
if (!normalized.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapperClassName = className
|
||||
? `breadcrumb-trail ${className}`.trim()
|
||||
: 'breadcrumb-trail';
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={containerRef} className={wrapperClassName}>
|
||||
{displayEntries.map((entry, index) => {
|
||||
const isEllipsis = entry.id === ELLIPSIS.id;
|
||||
const isLast = index === displayEntries.length - 1;
|
||||
|
||||
if (isEllipsis) {
|
||||
return (
|
||||
<React.Fragment key="breadcrumb-ellipsis">
|
||||
{index > 0 ? (
|
||||
<span className="breadcrumb-trail__separator" aria-hidden="true">
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="breadcrumb-trail__ellipsis">
|
||||
<button
|
||||
ref={ellipsisButtonRef}
|
||||
type="button"
|
||||
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={ellipsisMenuOpen}
|
||||
onClick={() => {
|
||||
if (!hasHiddenEntries) {
|
||||
closeEllipsisMenu();
|
||||
return;
|
||||
}
|
||||
toggleEllipsisMenu();
|
||||
}}
|
||||
title="Show parent folders"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
className: `breadcrumb-trail__link${isLast ? ' is-current' : ''}`,
|
||||
title: entry.label,
|
||||
'aria-current': isLast ? 'page' : undefined,
|
||||
};
|
||||
|
||||
const content = !entry.onClick || isLast
|
||||
? (
|
||||
<span key={`${entry.id}-label`} {...commonProps}>
|
||||
{entry.label}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
key={`${entry.id}-button`}
|
||||
type="button"
|
||||
{...commonProps}
|
||||
onClick={() => entry.onClick?.(entry.raw ?? entry)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={entry.id || index}>
|
||||
{index > 0 ? (
|
||||
<span className="breadcrumb-trail__separator" aria-hidden="true">
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
{content}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
ref={measurementRef}
|
||||
className="breadcrumb-trail breadcrumb-trail--measure"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
||||
data-item-type="ellipsis"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{ELLIPSIS.label}
|
||||
</button>
|
||||
{measurementEntries.map((entry, index) => {
|
||||
const isLast = index === measurementEntries.length - 1;
|
||||
const isInteractive = Boolean(entry.onClick) && !isLast;
|
||||
const MeasurementTag = isInteractive ? 'button' : 'span';
|
||||
|
||||
return (
|
||||
<React.Fragment key={`measure-${entry.id || index}`}>
|
||||
{index > 0 ? (
|
||||
<span
|
||||
className="breadcrumb-trail__separator"
|
||||
data-item-type="separator"
|
||||
data-target-index={index}
|
||||
>
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
<MeasurementTag
|
||||
type={isInteractive ? 'button' : undefined}
|
||||
className="breadcrumb-trail__link"
|
||||
data-item-type="entry"
|
||||
data-entry-index={index}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{entry.label}
|
||||
</MeasurementTag>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{ellipsisMenuOpen
|
||||
&& hasHiddenEntries
|
||||
&& ellipsisMenuStyle
|
||||
? createPortal(
|
||||
<div
|
||||
className="menu menu--floating"
|
||||
role="menu"
|
||||
ref={ellipsisMenuRef}
|
||||
style={ellipsisMenuStyle}
|
||||
data-floating-position
|
||||
>
|
||||
<div className="menu__list">
|
||||
{hiddenEntries.map((hiddenEntry) => (
|
||||
<button
|
||||
key={hiddenEntry.id}
|
||||
type="button"
|
||||
className="menu__item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
closeEllipsisMenu();
|
||||
hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry);
|
||||
}}
|
||||
disabled={!hiddenEntry.onClick}
|
||||
>
|
||||
<span className="menu__label">{hiddenEntry.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const breadcrumbEntryShape = PropTypes.shape({
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
name: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
});
|
||||
|
||||
BreadcrumbTrail.propTypes = {
|
||||
entries: PropTypes.arrayOf(breadcrumbEntryShape),
|
||||
className: PropTypes.string,
|
||||
separator: PropTypes.string,
|
||||
truncateFromStart: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default BreadcrumbTrail;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { ElementType, ReactNode } from 'react';
|
||||
import { composeClassName } from './classNames';
|
||||
|
||||
type TitleProps = Record<string, unknown>;
|
||||
|
||||
interface PanelHeaderProps {
|
||||
className?: string;
|
||||
leading?: ReactNode;
|
||||
title?: ReactNode;
|
||||
titleTag?: ElementType;
|
||||
titleProps?: TitleProps;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const PanelHeader: React.FC<PanelHeaderProps> = ({
|
||||
className = '',
|
||||
leading = null,
|
||||
title = null,
|
||||
titleTag: HeadingTag = 'h2',
|
||||
titleProps = {},
|
||||
actions = null,
|
||||
}) => {
|
||||
const headerClassName = composeClassName('panel-header', className);
|
||||
|
||||
const renderTitle = (): ReactNode => {
|
||||
if (title == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (React.isValidElement(title)) {
|
||||
if (title.type === React.Fragment) {
|
||||
return (
|
||||
<span className="panel-header__title" {...titleProps}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const existingClassName = (title.props as { className?: string })?.className ?? '';
|
||||
const mergedClassName = composeClassName('panel-header__title', existingClassName);
|
||||
return React.cloneElement(title, {
|
||||
...titleProps,
|
||||
className: mergedClassName,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<HeadingTag className="panel-header__title" {...titleProps}>
|
||||
{title}
|
||||
</HeadingTag>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={headerClassName}>
|
||||
{leading ? <div className="panel-header__leading">{leading}</div> : null}
|
||||
{renderTitle()}
|
||||
{actions ? <div className="panel-header__actions">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelHeader;
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode, FormEvent, MutableRefObject } from 'react';
|
||||
import { PlusIcon } from './icons';
|
||||
|
||||
import useFloatingMenu from './useFloatingMenu';
|
||||
|
||||
type QuickAddOption = string | { id?: string; label?: string; name?: string;[key: string]: unknown };
|
||||
|
||||
interface NormalizedOption {
|
||||
id?: string;
|
||||
label: string;
|
||||
original: QuickAddOption;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const normalizeOption = (option: QuickAddOption | null, index: number): NormalizedOption | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof option === 'object') {
|
||||
const label = option.label ?? option.name;
|
||||
if (label == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: option.id ?? label,
|
||||
label: String(label),
|
||||
original: option,
|
||||
index,
|
||||
};
|
||||
}
|
||||
const label = String(option);
|
||||
return {
|
||||
id: label,
|
||||
label,
|
||||
original: option,
|
||||
index,
|
||||
};
|
||||
};
|
||||
|
||||
interface FloatingMenuState {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
menuRef: MutableRefObject<HTMLDivElement | null>;
|
||||
menuStyle: CSSProperties | null;
|
||||
updatePosition: () => void;
|
||||
}
|
||||
|
||||
type CSSVarStyle = CSSProperties & Record<string, string>;
|
||||
|
||||
interface QuickAddMenuProps {
|
||||
onSelectOption?: (value: QuickAddOption, normalized: NormalizedOption) => Promise<void> | void;
|
||||
onCreate?: (value: string) => Promise<void> | void;
|
||||
options?: QuickAddOption[];
|
||||
placeholder?: string;
|
||||
createLabel?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
triggerAriaLabel?: string;
|
||||
triggerTitle?: string;
|
||||
renderOption?: (value: QuickAddOption, normalized: NormalizedOption) => ReactNode;
|
||||
menuMinWidth?: number;
|
||||
triggerClassName?: string;
|
||||
triggerContent?: ReactNode;
|
||||
disabled?: boolean;
|
||||
align?: 'start' | 'center' | 'end' | (string & {});
|
||||
positionStrategy?: 'fixed' | 'absolute' | (string & {});
|
||||
}
|
||||
|
||||
const QuickAddMenu = ({
|
||||
onSelectOption,
|
||||
onCreate,
|
||||
options = [],
|
||||
placeholder = 'Search or create…',
|
||||
createLabel = 'Add',
|
||||
emptyMessage = 'No matches',
|
||||
className,
|
||||
triggerAriaLabel = 'Add item',
|
||||
triggerTitle = 'Add',
|
||||
renderOption,
|
||||
menuMinWidth = 220,
|
||||
triggerClassName = 'icon-button quick-add__trigger',
|
||||
triggerContent = null,
|
||||
disabled = false,
|
||||
align = 'start',
|
||||
positionStrategy = 'fixed',
|
||||
}: QuickAddMenuProps) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
close,
|
||||
menuRef,
|
||||
menuStyle,
|
||||
updatePosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef,
|
||||
minWidth: menuMinWidth,
|
||||
matchAnchorWidth: false,
|
||||
align,
|
||||
positionStrategy,
|
||||
}) as FloatingMenuState;
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [disabled, isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
setQuery('');
|
||||
setSubmitting(false);
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select?.();
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const normalizedOptions = useMemo<NormalizedOption[]>(
|
||||
() =>
|
||||
options
|
||||
.map((option, index) => normalizeOption(option, index))
|
||||
.filter((option): option is NormalizedOption => Boolean(option)),
|
||||
[options],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
const search = query.trim().toLowerCase();
|
||||
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search));
|
||||
}, [normalizedOptions, query]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (option: NormalizedOption) => {
|
||||
if (!option || !onSelectOption) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSelectOption(option.original ?? option.label, option);
|
||||
setSubmitting(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
console.error('[quick-add] option selection failed', error);
|
||||
}
|
||||
},
|
||||
[close, onSelectOption],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!onCreate) {
|
||||
return;
|
||||
}
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreate(value);
|
||||
setSubmitting(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
console.error('[quick-add] creation failed', error);
|
||||
}
|
||||
},
|
||||
[close, onCreate, query],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(onCreate);
|
||||
const isAnchoredMenu = positionStrategy === 'absolute' && align === 'start';
|
||||
const menuClassName = 'menu menu--floating';
|
||||
const anchoredMenuStyle = isAnchoredMenu && menuStyle
|
||||
? {
|
||||
top: menuStyle.top,
|
||||
left: menuStyle.left,
|
||||
...(menuStyle.width ? { width: menuStyle.width } : null),
|
||||
}
|
||||
: undefined;
|
||||
const menuInlineStyle = (isAnchoredMenu ? anchoredMenuStyle : menuStyle || undefined) as CSSVarStyle | undefined;
|
||||
const hasFloatingWidthVar = Boolean(menuInlineStyle && Object.prototype.hasOwnProperty.call(menuInlineStyle, '--floating-min-width'));
|
||||
const menuStyleWithVar: CSSVarStyle | undefined = hasFloatingWidthVar
|
||||
? menuInlineStyle
|
||||
: {
|
||||
...(menuInlineStyle || {}),
|
||||
'--floating-min-width': `${Math.max(menuMinWidth, 0)}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
||||
<button
|
||||
type="button"
|
||||
ref={anchorRef}
|
||||
className={triggerClassName}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
onClick={toggle}
|
||||
aria-label={triggerAriaLabel}
|
||||
title={triggerTitle}
|
||||
disabled={disabled}
|
||||
>
|
||||
{triggerContent ?? <PlusIcon />}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className={menuClassName}
|
||||
ref={menuRef}
|
||||
style={menuStyleWithVar}
|
||||
role="menu"
|
||||
data-floating-position
|
||||
>
|
||||
{canCreate ? (
|
||||
<form className="quick-add__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={submitting}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
<button type="submit" disabled={submitting || !query.trim()}>
|
||||
{createLabel}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<div className="menu__list" role="presentation">
|
||||
{filteredOptions.length ? (
|
||||
filteredOptions.map((option) => {
|
||||
const key = option.id ?? option.index;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="menu__item"
|
||||
role="menuitem"
|
||||
onClick={() => handleSelect(option)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{renderOption ? (
|
||||
renderOption(option.original ?? option.label, option)
|
||||
) : (
|
||||
<span className="menu__label">{option.label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="menu__empty">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddMenu;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useStatusToast } from '../lib/context/StatusToastContext';
|
||||
import '../styles/status-toast.css';
|
||||
|
||||
const FADE_OUT_DURATION = 300; // Match CSS animation duration
|
||||
|
||||
const StatusToastOverlay: React.FC = () => {
|
||||
const { toasts, removeToast } = useStatusToast();
|
||||
const [exitingToasts, setExitingToasts] = useState<Set<string>>(new Set());
|
||||
const [displayToasts, setDisplayToasts] = useState(toasts);
|
||||
const prevToastIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Detect when toasts are removed from context and trigger fade
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(toasts.map(t => t.id));
|
||||
const prevIds = prevToastIdsRef.current;
|
||||
|
||||
// Find toasts that were removed
|
||||
const removedIds = Array.from(prevIds).filter((id) => typeof id === 'string' && !currentIds.has(id));
|
||||
|
||||
// Trigger fade for removed toasts
|
||||
if (removedIds.length > 0) {
|
||||
setExitingToasts(prev => {
|
||||
const next = new Set(prev);
|
||||
removedIds.forEach(id => next.add(id));
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove from display after fade
|
||||
setTimeout(() => {
|
||||
setDisplayToasts(current => current.filter(t => !removedIds.includes(t.id)));
|
||||
setExitingToasts(prev => {
|
||||
const next = new Set(prev);
|
||||
removedIds.forEach(id => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, FADE_OUT_DURATION);
|
||||
}
|
||||
|
||||
// Add new toasts to display
|
||||
const newToasts = toasts.filter(t => !prevIds.has(t.id));
|
||||
if (newToasts.length > 0) {
|
||||
setDisplayToasts(toasts);
|
||||
}
|
||||
|
||||
prevToastIdsRef.current = currentIds;
|
||||
}, [toasts]);
|
||||
|
||||
const handleRemove = useCallback((id: string) => {
|
||||
removeToast(id);
|
||||
}, [removeToast]);
|
||||
|
||||
if (displayToasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-toast-container">
|
||||
{[...displayToasts].reverse().map((toast) => {
|
||||
const isExiting = exitingToasts.has(toast.id);
|
||||
const classNames = [
|
||||
'status-toast-pill',
|
||||
`status-toast-pill--${toast.variant}`,
|
||||
isExiting ? 'status-toast-pill--exiting' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={classNames}
|
||||
onClick={() => handleRemove(toast.id)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusToastOverlay;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const composeClassName = (base: string, extra?: string | null): string =>
|
||||
extra ? `${base} ${extra}` : base;
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import type { IconProps as TablerIconProps } from '@tabler/icons-react';
|
||||
import {
|
||||
IconChevronRight as TablerChevronRight,
|
||||
IconDownload as TablerDownload,
|
||||
IconZoomInArea as TablerZoomInArea,
|
||||
IconPencil,
|
||||
IconTagFilled,
|
||||
IconUserFilled,
|
||||
IconTrash,
|
||||
IconLayoutList,
|
||||
IconLayoutGrid,
|
||||
IconArrowLeft,
|
||||
IconAnalyze,
|
||||
IconUpload,
|
||||
IconWindowMaximize,
|
||||
IconFolderPlus,
|
||||
IconFolder,
|
||||
IconFolders,
|
||||
IconFoldersOff,
|
||||
IconRefresh,
|
||||
IconRestore,
|
||||
IconMinusVertical,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconX as TablerIconX,
|
||||
IconSettings,
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconLayoutBottombarCollapse,
|
||||
IconLayoutBottombarExpand,
|
||||
IconInfoCircle,
|
||||
IconCircleDashedCheck,
|
||||
IconFile,
|
||||
IconLoader,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconFileInfo,
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconBrandMatrix,
|
||||
IconWorld,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
const logoWebp = new URL('../assets/logo.webp', import.meta.url).toString();
|
||||
const logoSmallWebp = new URL('../assets/logo_small.webp', import.meta.url).toString();
|
||||
import { composeClassName } from './classNames';
|
||||
|
||||
type TablerIconComponent = (props: TablerIconProps) => JSX.Element;
|
||||
|
||||
// Factory for creating standard icon wrappers with consistent defaults
|
||||
const createIcon = (
|
||||
Icon: TablerIconComponent,
|
||||
{ baseClass = 'icon', defaultStroke = 1.6 }: { baseClass?: string; defaultStroke?: number } = {},
|
||||
): TablerIconComponent => {
|
||||
const WrappedIcon: TablerIconComponent = ({ className, size = '1em', stroke = defaultStroke, ...rest }) => (
|
||||
<Icon
|
||||
className={composeClassName(baseClass, className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
return WrappedIcon;
|
||||
};
|
||||
|
||||
// Standard stroke icons (stroke = 1.6)
|
||||
export const ChevronIcon = createIcon(TablerChevronRight);
|
||||
export const TrashIcon = createIcon(IconTrash);
|
||||
export const EditIcon = createIcon(IconPencil);
|
||||
export const DownloadIcon = createIcon(TablerDownload);
|
||||
export const IconZoomInArea = createIcon(TablerZoomInArea);
|
||||
export const GithubIcon = createIcon(IconBrandGithub);
|
||||
export const MatrixIcon = createIcon(IconBrandMatrix);
|
||||
export const WorldIcon = createIcon(IconWorld);
|
||||
export const ViewListIcon = createIcon(IconLayoutList);
|
||||
export const ViewGridIcon = createIcon(IconLayoutGrid);
|
||||
export const UploadIcon = createIcon(IconUpload);
|
||||
export const ArrowLeftIcon = createIcon(IconArrowLeft);
|
||||
export const SidebarCollapseIcon = createIcon(IconLayoutSidebarLeftCollapse);
|
||||
export const SidebarExpandIcon = createIcon(IconLayoutSidebarLeftExpand);
|
||||
export const InfoIcon = createIcon(IconInfoCircle);
|
||||
export const FileInfoIcon = createIcon(IconFileInfo);
|
||||
export const BottombarCollapseIcon = createIcon(IconLayoutBottombarCollapse);
|
||||
export const BottombarExpandIcon = createIcon(IconLayoutBottombarExpand);
|
||||
export const FolderPlusIcon = createIcon(IconFolderPlus);
|
||||
export const FoldersIcon = createIcon(IconFolders);
|
||||
export const FoldersOffIcon = createIcon(IconFoldersOff);
|
||||
export const RefreshIcon = createIcon(IconRefresh);
|
||||
export const RestoreIcon = createIcon(IconRestore);
|
||||
export const MinusVerticalIcon = createIcon(IconMinusVertical);
|
||||
export const SortAscendingLettersIcon = createIcon(IconSortAscendingLetters);
|
||||
export const SortDescendingLettersIcon = createIcon(IconSortDescendingLetters);
|
||||
export const IconX = createIcon(TablerIconX);
|
||||
export const CloseIcon = createIcon(TablerIconX);
|
||||
export const SettingsIcon = createIcon(IconSettings);
|
||||
export const PlusIcon = createIcon(IconPlus);
|
||||
export const SunIcon = createIcon(IconSun);
|
||||
export const MoonIcon = createIcon(IconMoon);
|
||||
export const DesktopIcon = createIcon(IconDeviceLaptop);
|
||||
export const CheckIcon = createIcon(IconCheck);
|
||||
export const CircleDashedCheckIcon = createIcon(IconCircleDashedCheck);
|
||||
export const FileIcon = createIcon(IconFile);
|
||||
export const FolderOutlineIcon = createIcon(IconFolder);
|
||||
export const AnalyzeIcon = createIcon(IconAnalyze);
|
||||
export const WindowMaximizeIcon = createIcon(IconWindowMaximize);
|
||||
export const LogoutIcon = createIcon(IconLogout);
|
||||
export const ChevronDownIcon = createIcon(IconChevronDown);
|
||||
|
||||
// Icons with different default stroke
|
||||
export const LoaderIcon = createIcon(IconLoader, { defaultStroke: 1.8 });
|
||||
export const WarningIcon = createIcon(IconAlertTriangle, { defaultStroke: 1.8 });
|
||||
|
||||
// Filled icons (stroke = 0)
|
||||
export const TagIcon = createIcon(IconTagFilled, { baseClass: 'icon icon--fill', defaultStroke: 0 });
|
||||
export const CorrespondentIcon = createIcon(IconUserFilled, { baseClass: 'icon icon--fill', defaultStroke: 0 });
|
||||
|
||||
// Custom icons that need special handling
|
||||
export const FolderIcon: TablerIconComponent = ({ className, size = 16, title, ...rest }) => {
|
||||
return (
|
||||
<FolderSvg
|
||||
className={composeClassName('folder-icon', className)}
|
||||
width={size}
|
||||
height={size}
|
||||
role={title ? 'img' : 'presentation'}
|
||||
aria-hidden={title ? undefined : true}
|
||||
focusable="false"
|
||||
title={title}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface LogoIconProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
variant?: 'default' | 'small';
|
||||
}
|
||||
|
||||
export const LogoIcon: React.FC<LogoIconProps> = ({ className, width = 24, height = 24, variant = 'default' }) => {
|
||||
const src = variant === 'small' ? logoSmallWebp : logoWebp;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
className={composeClassName('logo-icon', className)}
|
||||
width={width}
|
||||
height={height}
|
||||
alt="Papercrate logo"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconFileStack: TablerIconComponent = ({ className, size = 24, stroke = 160, ...rest }) => (
|
||||
<svg
|
||||
className={composeClassName('icon', className)}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
{...rest}
|
||||
>
|
||||
<path d="M 17.9392 19.2642 c 0.6299 -0.3377 1.0608 -1.0031 1.0608 -1.7642 l 0 -11.4909 l 2.502 0.5318 c 1.0329 0.2195 1.6984 1.2443 1.4788 2.2772 l -2.6346 12.3951 c -0.2196 1.0329 -1.2443 1.6984 -2.2772 1.4789 l -5.1403 -1.0926 l 3.4203 -0.727 c 0.8245 -0.1753 1.4308 -0.8288 1.5902 -1.6083 Z" />
|
||||
<path d="M 5 5.173 l 0 -1.673 c 0 -1.1 0.9 -2 2 -2 l 10 0 c 1.1 0 2 0.9 2 2 l 0 14 c 0 0.7611 -0.4309 1.4265 -1.0608 1.7642 c 0.0548 -0.2682 0.0567 -0.5513 -0.0036 -0.835 l -2.8267 -13.2989 c -0.2356 -1.1082 -1.3351 -1.8223 -2.4433 -1.5867 l -7.6656 1.6294 Z" />
|
||||
<path d="M 16.349 20.8725 l -10.075 2.1415 c -1.1082 0.2355 -2.2077 -0.4785 -2.4432 -1.5867 l -2.8268 -13.2989 c -0.2356 -1.1083 0.4784 -2.2077 1.5867 -2.4433 l 10.0749 -2.1415 c 1.1082 -0.2356 2.2077 0.4785 2.4433 1.5867 l 2.8267 13.2989 c 0.2356 1.1082 -0.4784 2.2077 -1.5866 2.4433 Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FolderMoveIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={composeClassName('icon', className)}
|
||||
{...rest}
|
||||
>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-2m0 -6v-3a2 2 0 0 1 2 -2" />
|
||||
</g>
|
||||
<g transform="translate(-4, 0)">
|
||||
<path d="M5 12l11 0"></path>
|
||||
<path d="M13 16l4 -4"></path>
|
||||
<path d="M13 8l4 4"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject, CSSProperties } from 'react';
|
||||
import { clamp } from '../utils/math';
|
||||
import { DEFAULT_VIEWPORT_MARGIN } from '../constants/ui';
|
||||
|
||||
type PositionStrategy = 'fixed' | 'absolute' | (string & {});
|
||||
|
||||
interface FloatingMenuMetrics {
|
||||
strategy: PositionStrategy;
|
||||
top: number;
|
||||
left: number;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
type FloatingMenuStyle = (CSSProperties & { '--floating-min-width'?: string }) | null;
|
||||
|
||||
const resolveViewportWidth = () => window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
|
||||
const computeWidth = (anchorWidth: number, minWidth: number, matchAnchorWidth: boolean) => {
|
||||
if (matchAnchorWidth) {
|
||||
return Math.max(anchorWidth, minWidth);
|
||||
}
|
||||
return Math.max(minWidth || 0, anchorWidth || 0);
|
||||
};
|
||||
|
||||
const formatStyle = (metrics: FloatingMenuMetrics | null): FloatingMenuStyle => {
|
||||
if (!metrics) {
|
||||
return null;
|
||||
}
|
||||
const style: FloatingMenuStyle = {
|
||||
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
||||
top: metrics.top,
|
||||
left: metrics.left,
|
||||
};
|
||||
if (metrics.minWidth != null) {
|
||||
style['--floating-min-width'] = `${Math.max(metrics.minWidth, 0)}px`;
|
||||
}
|
||||
if (metrics.width) {
|
||||
style.width = metrics.width;
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
interface UseFloatingMenuOptions {
|
||||
anchorRef?: MutableRefObject<HTMLElement | null>;
|
||||
offset?: number;
|
||||
minWidth?: number;
|
||||
matchAnchorWidth?: boolean;
|
||||
align?: 'start' | 'center' | 'end' | (string & {});
|
||||
viewportMargin?: number;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
positionStrategy?: PositionStrategy;
|
||||
}
|
||||
|
||||
const useFloatingMenu = ({
|
||||
anchorRef,
|
||||
offset = 6,
|
||||
minWidth = 0,
|
||||
matchAnchorWidth = false,
|
||||
align = 'start',
|
||||
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
||||
onOpenChange,
|
||||
positionStrategy = 'fixed',
|
||||
}: UseFloatingMenuOptions = {}) => {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuMetrics, setMenuMetrics] = useState<FloatingMenuMetrics | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
}, [onOpenChange]);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef?.current;
|
||||
if (!anchor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||
const menu = menuRef.current;
|
||||
const measuredWidth = menu?.offsetWidth ?? desiredWidth;
|
||||
const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth);
|
||||
|
||||
if (positionStrategy === 'absolute') {
|
||||
const anchor = anchorRef?.current;
|
||||
if (!anchor) {
|
||||
return false;
|
||||
}
|
||||
const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement;
|
||||
if (!offsetParent) {
|
||||
// Fall back to fixed positioning if we cannot resolve a relative parent.
|
||||
setMenuMetrics({
|
||||
strategy: 'fixed',
|
||||
top: rect.bottom + offset,
|
||||
left: rect.left,
|
||||
minWidth: desiredWidth,
|
||||
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment;
|
||||
} else if (align === 'center') {
|
||||
left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2;
|
||||
} else {
|
||||
left = anchor.offsetLeft;
|
||||
}
|
||||
|
||||
const top = anchor.offsetTop + anchor.offsetHeight + offset;
|
||||
|
||||
setMenuMetrics({
|
||||
strategy: 'absolute',
|
||||
top,
|
||||
left,
|
||||
minWidth: desiredWidth,
|
||||
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const viewportWidth = resolveViewportWidth();
|
||||
const viewportHeight = window.innerHeight || 0;
|
||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||
const menuHeight = menu?.offsetHeight ?? 0;
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
left = rect.right - widthForAlignment;
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + rect.width / 2 - widthForAlignment / 2;
|
||||
} else {
|
||||
left = rect.left;
|
||||
}
|
||||
|
||||
const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left;
|
||||
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||
|
||||
let top = rect.bottom + offset;
|
||||
if (viewportHeight > 0 && menuHeight > 0) {
|
||||
const projectedBottom = top + menuHeight + safeMargin;
|
||||
if (projectedBottom > viewportHeight) {
|
||||
const upwardTop = rect.top - offset - menuHeight;
|
||||
top = Math.max(upwardTop, safeMargin);
|
||||
}
|
||||
}
|
||||
|
||||
setMenuMetrics({
|
||||
strategy: 'fixed',
|
||||
top,
|
||||
left: clampedLeft,
|
||||
minWidth: desiredWidth,
|
||||
width: matchAnchorWidth ? desiredWidth : undefined,
|
||||
});
|
||||
|
||||
return true;
|
||||
}, [
|
||||
anchorRef,
|
||||
align,
|
||||
matchAnchorWidth,
|
||||
minWidth,
|
||||
offset,
|
||||
positionStrategy,
|
||||
viewportMargin,
|
||||
]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
onOpenChangeRef.current?.(false);
|
||||
return false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpen((prev) => {
|
||||
if (prev) {
|
||||
return prev;
|
||||
}
|
||||
const positioned = updatePosition();
|
||||
if (!positioned) {
|
||||
onOpenChangeRef.current?.(false);
|
||||
return prev;
|
||||
}
|
||||
onOpenChangeRef.current?.(true);
|
||||
return true;
|
||||
});
|
||||
}, [updatePosition]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => {
|
||||
if (prev) {
|
||||
onOpenChangeRef.current?.(false);
|
||||
return false;
|
||||
}
|
||||
const positioned = updatePosition();
|
||||
if (!positioned) {
|
||||
onOpenChangeRef.current?.(false);
|
||||
return prev;
|
||||
}
|
||||
onOpenChangeRef.current?.(true);
|
||||
return true;
|
||||
});
|
||||
}, [updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let ignoreFocusEvents = true;
|
||||
const raf = window.requestAnimationFrame;
|
||||
const rafId = raf
|
||||
? raf(() => {
|
||||
ignoreFocusEvents = false;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!anchorRef?.current) {
|
||||
close();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handlePointer = (event) => {
|
||||
if (event.type === 'focusin' && ignoreFocusEvents) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
// If the target is no longer in the document, it means it was unmounted
|
||||
// (e.g. due to a re-render caused by the open action).
|
||||
// In this case, we should ignore the event.
|
||||
if (target instanceof Node && !document.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = menuRef.current;
|
||||
const anchor = anchorRef?.current;
|
||||
|
||||
if ((anchor && anchor.contains(target)) || (menu && menu.contains(target))) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// Delay adding listeners to avoid capturing the event that opened the menu
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer, { passive: true });
|
||||
document.addEventListener('focusin', handlePointer);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (rafId != null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
document.removeEventListener('focusin', handlePointer);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [anchorRef, close, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleRelayout = () => {
|
||||
const positioned = updatePosition();
|
||||
if (!positioned) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
handleRelayout();
|
||||
window.addEventListener('resize', handleRelayout);
|
||||
window.addEventListener('scroll', handleRelayout, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleRelayout);
|
||||
window.removeEventListener('scroll', handleRelayout, true);
|
||||
};
|
||||
}, [close, isOpen, updatePosition]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
menuRef,
|
||||
menuStyle: formatStyle(menuMetrics),
|
||||
updatePosition,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFloatingMenu;
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ENTRY_KEY_SEPARATOR = ':';
|
||||
export const TAGS_MODAL = 'tags';
|
||||
export const CORRESPONDENTS_MODAL = 'correspondents';
|
||||
export const STORED_TOKEN_KEY = 'papercrate_token';
|
||||
@@ -0,0 +1 @@
|
||||
export const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DB_NAME = 'papercrate_desk';
|
||||
export const DB_VERSION = 1;
|
||||
export const LAYOUT_STORE = 'layouts';
|
||||
@@ -0,0 +1,20 @@
|
||||
export const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||
|
||||
export const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'] as const;
|
||||
export const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||
|
||||
export const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
export const DEFAULT_LIST_ICON_SIZE = 48;
|
||||
export const DEFAULT_DESKTOP_CARD_SIZE = 300;
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'issued_at', label: 'Issued date' },
|
||||
{ value: 'created_at', label: 'Added' },
|
||||
{ value: 'updated_at', label: 'Updated date' },
|
||||
] as const;
|
||||
|
||||
export const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.value] = option.label;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -0,0 +1,23 @@
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
export const DEFAULT_DETAIL_WIDTH = 420;
|
||||
export const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
export const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
|
||||
export const MINIMUM_MAIN_CONTENT_WIDTH = 160;
|
||||
|
||||
export type PanelKey = 'sidebar' | 'detail';
|
||||
|
||||
export const PANEL_LIMITS: Record<PanelKey, { maxRatio: number; minPx: number }> = {
|
||||
sidebar: {
|
||||
maxRatio: 1 / 3,
|
||||
minPx: 280,
|
||||
},
|
||||
detail: {
|
||||
maxRatio: 2 / 3,
|
||||
minPx: 320,
|
||||
},
|
||||
};
|
||||
|
||||
export const PANEL_STORAGE_KEYS: Record<PanelKey, string> = {
|
||||
sidebar: 'papercrate_sidebar_width',
|
||||
detail: 'papercrate_detail_width',
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export const RERENDER_DELTA = 48;
|
||||
export const MAX_PIXEL_RATIO = 2;
|
||||
export const FAST_SCROLL_VELOCITY_THRESHOLD = 0.8;
|
||||
export const FAST_SCROLL_DWELL_THRESHOLD_MS = 120;
|
||||
export const SCROLL_VELOCITY_MIN_DELTA = 0.05;
|
||||
|
||||
export const AUDIO_EXTENSIONS = new Set([
|
||||
'aac',
|
||||
'aiff',
|
||||
'flac',
|
||||
'm4a',
|
||||
'mp3',
|
||||
'ogg',
|
||||
'oga',
|
||||
'opus',
|
||||
'wav',
|
||||
'weba',
|
||||
]);
|
||||
|
||||
export const VIDEO_EXTENSIONS = new Set([
|
||||
'avi',
|
||||
'mkv',
|
||||
'mov',
|
||||
'mp4',
|
||||
'm4v',
|
||||
'webm',
|
||||
'wmv',
|
||||
]);
|
||||
|
||||
export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4;
|
||||
export const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style';
|
||||
export const MIN_STACKED_BREAKPOINT = 480;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SettingsSectionConfig } from '../settings/SettingsModal';
|
||||
import ApiTokensSection from '../settings/sections/ApiTokensSection';
|
||||
import CapabilitySetsSection from '../settings/sections/CapabilitySetsSection';
|
||||
import PasskeysSection from '../settings/sections/PasskeysSection';
|
||||
|
||||
const PASSKEYS_SECTION: SettingsSectionConfig = {
|
||||
id: 'passkeys',
|
||||
label: 'Passkeys',
|
||||
component: PasskeysSection,
|
||||
};
|
||||
|
||||
const API_TOKENS_SECTION: SettingsSectionConfig = {
|
||||
id: 'apiTokens',
|
||||
label: 'API tokens',
|
||||
component: ApiTokensSection,
|
||||
};
|
||||
|
||||
const CAPABILITY_SETS_SECTION: SettingsSectionConfig = {
|
||||
id: 'capabilitySets',
|
||||
label: 'Capability sets',
|
||||
component: CapabilitySetsSection,
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS_SECTIONS = [
|
||||
PASSKEYS_SECTION,
|
||||
API_TOKENS_SECTION,
|
||||
CAPABILITY_SETS_SECTION,
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
export const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed';
|
||||
export const THEME_STORAGE_KEY = 'papercrate_theme_settings';
|
||||
export const DEFAULT_NEUTRAL_HUE = 29;
|
||||
export const DEFAULT_NEUTRAL_CHROMA = 0.44;
|
||||
export const DEFAULT_THEME_MODE = 'system';
|
||||
export const THEME_MODES = ['system', 'light', 'dark'];
|
||||
export const THEME_MODE_LABELS = {
|
||||
system: 'System default',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
};
|
||||
export const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const NBSP = String.fromCharCode(160);
|
||||
export const DEFAULT_VIEWPORT_MARGIN = 8;
|
||||
|
||||
export const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null } as const;
|
||||
export const WIDTH_TOLERANCE = 1;
|
||||
export const WIDTH_BUFFER_RATIO = 0.99;
|
||||
export const WIDTH_CHANGE_TOLERANCE = 0.02;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DEFAULT_FOLDER_NAME = 'Documents';
|
||||
export const DEFAULT_SORT_FIELD = 'title';
|
||||
export const DEFAULT_SORT_DIRECTION = 'asc';
|
||||
export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
|
||||
export const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
|
||||
|
||||
export const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode';
|
||||
export const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
||||
export const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||
export const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { FormEvent, KeyboardEvent } from 'react';
|
||||
import type { Correspondent } from '../types/documents';
|
||||
|
||||
export interface CorrespondentsPanelProps {
|
||||
correspondents?: Correspondent[];
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
onCreate: (payload: { name: string }) => Promise<Correspondent | void>;
|
||||
onUpdate: (id: string, payload: { name: string }) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onNotify?: (message: string, variant?: string) => void;
|
||||
}
|
||||
|
||||
function CorrespondentsPanel({
|
||||
correspondents = [],
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onNotify,
|
||||
}: CorrespondentsPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const startEdit = useCallback((correspondent: Correspondent) => {
|
||||
setEditingId(correspondent.id);
|
||||
setDraftName(correspondent.name);
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftName('');
|
||||
setSaving(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
const trimmed = draftName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onUpdate(editingId, { name: trimmed });
|
||||
cancelEdit();
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to update correspondent.', 'error');
|
||||
console.error('[correspondents] update failed', error);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (correspondent: Correspondent) => {
|
||||
if (!correspondent?.id) return;
|
||||
setDeletingId(correspondent.id);
|
||||
try {
|
||||
await onDelete(correspondent.id);
|
||||
if (editingId === correspondent.id) {
|
||||
cancelEdit();
|
||||
}
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to delete correspondent.', 'error');
|
||||
console.error('[correspondents] delete failed', error);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[onDelete, editingId, cancelEdit, onNotify],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) {
|
||||
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({ name: trimmed });
|
||||
setCreateName('');
|
||||
} catch (error) {
|
||||
onNotify?.('Failed to create correspondent.', 'error');
|
||||
console.error('[correspondents] create failed', error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[createName, onCreate, onNotify],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
},
|
||||
[handleSave, cancelEdit],
|
||||
);
|
||||
|
||||
const renderUsage = useCallback((correspondent: Correspondent) => {
|
||||
return correspondent.usage_count;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel">
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>Correspondents</h2>
|
||||
<div className="panel-section__subtitle">{correspondents.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions correspondents-actions">
|
||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New correspondent name"
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" disabled={creating || !createName.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={saving || creating || Boolean(deletingId)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-section__body tags-panel__body">
|
||||
{correspondents.length === 0 ? (
|
||||
<div className="empty-state">No correspondents created yet.</div>
|
||||
) : (
|
||||
<div className="tags-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col" className="numeric">
|
||||
Usage
|
||||
</th>
|
||||
<th scope="col" className="actions">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{correspondents.map((correspondent) => {
|
||||
const isEditing = editingId === correspondent.id;
|
||||
return (
|
||||
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
|
||||
<td className="tags-table__label">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="tags-table__label-input"
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span>{correspondent.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="numeric">{renderUsage(correspondent)}</td>
|
||||
<td className="actions">
|
||||
{isEditing ? (
|
||||
<div className="tags-table__edit-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tags-table__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => startEdit(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => handleDelete(correspondent)}
|
||||
disabled={deletingId === correspondent.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CorrespondentsPanel;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DesktopPreviewCard from './DesktopPreviewCard';
|
||||
import { resolveCorrespondents } from '../../documents/correspondents';
|
||||
import type { Document } from '../../types/documents';
|
||||
import { LayoutCard } from '../logic/LayoutSystem';
|
||||
import { useCardPointer } from '../interactions/useCardPointer';
|
||||
import DocumentTags from '../../documents/components/DocumentTags';
|
||||
import { TagInteractionHandlers } from '../../documents/interactions/useTagInteractions';
|
||||
import { useDocumentsAssetContext } from '../../documents/context/DocumentsAssetContext';
|
||||
import { useDocumentsViewStateContext } from '../../documents/context/DocumentsViewStateContext';
|
||||
|
||||
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
|
||||
if (!event) return;
|
||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
||||
};
|
||||
|
||||
interface DesktopDocumentCardProps {
|
||||
doc: Document;
|
||||
style?: React.CSSProperties;
|
||||
shouldLoad?: boolean;
|
||||
matchesFilter?: boolean;
|
||||
selected?: boolean;
|
||||
docTagTokens?: string;
|
||||
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
onDocumentActivate?: (id: string, event?: any) => void;
|
||||
onSelect: (ids: string[], extend?: boolean) => void;
|
||||
onDeselect: (ids: string[]) => void;
|
||||
selection: string[];
|
||||
requestCanvasFocus?: () => void;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
layoutCard: LayoutCard;
|
||||
}
|
||||
|
||||
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
doc,
|
||||
style,
|
||||
shouldLoad = false,
|
||||
matchesFilter = true,
|
||||
selected = false,
|
||||
docTagTokens,
|
||||
onDocumentActivate,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
selection,
|
||||
requestCanvasFocus,
|
||||
tagHandlers,
|
||||
layoutCard,
|
||||
}) => {
|
||||
const {
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset
|
||||
} = useDocumentsAssetContext();
|
||||
|
||||
const { tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||
const cardPointerHandlers = useCardPointer(
|
||||
layoutCard,
|
||||
!!selected,
|
||||
selection,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
onDocumentActivate,
|
||||
requestCanvasFocus
|
||||
);
|
||||
|
||||
const correspondents = useMemo(() => resolveCorrespondents(doc, correspondentLookupById), [doc, correspondentLookupById]);
|
||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||
|
||||
const itemClasses = ['desk-item'];
|
||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||
if (selected) itemClasses.push('is-selected');
|
||||
|
||||
const ariaHidden = matchesFilter ? undefined : 'true';
|
||||
const dataTagIds = docTagTokens || undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={itemClasses.join(' ')}
|
||||
style={style}
|
||||
role="button"
|
||||
data-doc-id={doc.id}
|
||||
data-tag-ids={dataTagIds}
|
||||
aria-hidden={ariaHidden}
|
||||
ref={(node) => layoutCard?.setRef(node)}
|
||||
{...cardPointerHandlers}
|
||||
onDragEnter={(event) => tagHandlers?.onTagDragEnter(event, doc.id!)}
|
||||
onDragOver={(event) => tagHandlers?.onTagDragOver(event, doc)}
|
||||
onDragLeave={(event) => tagHandlers?.onTagDragLeave(event, doc.id!)}
|
||||
onDrop={(event) => tagHandlers?.onTagDrop(event, doc)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
preventAll(event);
|
||||
onDocumentActivate?.(doc.id, event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="desk-item__body">
|
||||
<DesktopPreviewCard
|
||||
doc={doc}
|
||||
title={doc.title}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
shouldLoad={shouldLoad}
|
||||
/>
|
||||
{correspondents.length > 0 && (
|
||||
<div className="desk-item__correspondents" aria-hidden="true">
|
||||
{correspondents.map((correspondent) => (
|
||||
<span
|
||||
key={correspondent.key}
|
||||
className="badge desk-correspondent-chip"
|
||||
title={correspondent.name}
|
||||
>
|
||||
<span className="desk-correspondent-chip__label">{correspondent.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div className="desk-item__tags" aria-hidden="true">
|
||||
<DocumentTags
|
||||
doc={doc}
|
||||
tags={tags}
|
||||
tagLookupById={tagLookupById}
|
||||
tagHandlers={tagHandlers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DesktopDocumentCard);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../../lib/assets/AssetManager';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
import type { Asset } from '../../types/assets';
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: Asset,
|
||||
options?: { force?: boolean;[key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetDocumentAsset = (document: Document | null, assetType: string) => Asset | null;
|
||||
|
||||
interface DesktopPreviewCardProps {
|
||||
doc: Document | null;
|
||||
title?: string;
|
||||
ensureAssetUrl?: EnsureAssetUrl | null;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
shouldLoad?: boolean;
|
||||
}
|
||||
|
||||
const DesktopPreviewCard = ({
|
||||
doc,
|
||||
title,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
shouldLoad = true,
|
||||
}: DesktopPreviewCardProps): JSX.Element => {
|
||||
const currentUrl = useMemo(() => {
|
||||
if (!doc) return null;
|
||||
return resolveDocumentAssetUrl(doc, 'thumbnail', {
|
||||
ensureAssetUrl: shouldLoad && ensureAssetUrl ? ensureAssetUrl : undefined,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
}, [doc, ensureAssetUrl, getDocumentAsset, shouldLoad]);
|
||||
|
||||
const hasPreview = Boolean(currentUrl);
|
||||
const cardClasses = ['desk-item__card'];
|
||||
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
||||
return (
|
||||
<div
|
||||
className={cardClasses.join(' ')}
|
||||
onDragStart={(event) => {
|
||||
if (event instanceof DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={title}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="desk-item__empty">
|
||||
<div className="desk-item__placeholder">DOC</div>
|
||||
<div className="desk-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopPreviewCard;
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { LayoutStore, LayoutCard } from '../logic/LayoutSystem';
|
||||
import DesktopDocumentCard from './DesktopDocumentCard';
|
||||
import usePreviewMetadata from '../hooks/usePreviewMetadata';
|
||||
import {
|
||||
TagInteractionHandlers,
|
||||
} from '../../documents/interactions/useTagInteractions';
|
||||
import './workspace-layout.css';
|
||||
import './workspace-items.css';
|
||||
import './workspace-cards.css';
|
||||
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
||||
import { createDocumentEntryKey } from '../../app/entryKey';
|
||||
import { PointerTrackingProvider, usePointerTracking } from '../interactions/PointerTrackingContext';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { DocumentsListEntry, Document } from '../../types/documents';
|
||||
import { useAppState } from '../../lib/store/appState';
|
||||
import { useDocumentOpen } from '../../lib/context/DocumentOpenContext';
|
||||
import { useDocumentsAssetContext } from '../../documents/context/DocumentsAssetContext';
|
||||
import { useDocumentsViewStateContext } from '../../documents/context/DocumentsViewStateContext';
|
||||
|
||||
interface DocumentSizeInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
source?: 'snapshot' | 'metadata' | 'fallback';
|
||||
}
|
||||
|
||||
// Fallback size computation
|
||||
const computeFallbackCardSize = (_doc: Document, defaultSize: number = 200): DocumentSizeInfo => {
|
||||
const size = Math.round(defaultSize * (1 / Math.SQRT2));
|
||||
return { width: size, height: size, source: 'fallback' };
|
||||
};
|
||||
|
||||
interface DesktopWorkspaceProps {
|
||||
entries: DocumentsListEntry[];
|
||||
onSelectionChange?: (selectedIds: Identifier[]) => void;
|
||||
viewId?: string | null;
|
||||
defaultCardSize?: number;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
}
|
||||
|
||||
const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
||||
entries,
|
||||
onSelectionChange,
|
||||
viewId,
|
||||
defaultCardSize = 200,
|
||||
tagHandlers,
|
||||
}) => {
|
||||
const { openDocument } = useDocumentOpen();
|
||||
const {
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset
|
||||
} = useDocumentsAssetContext();
|
||||
|
||||
const { tenant } = useAppState();
|
||||
const tenantId = tenant?.id as Identifier;
|
||||
const { addPointer, removePointer } = usePointerTracking();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLayoutReady, setIsLayoutReady] = useState(false);
|
||||
const [isLayoutLoaded, setIsLayoutLoaded] = useState(false);
|
||||
const [hasContainerSize, setHasContainerSize] = useState(false);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return entries
|
||||
.filter((entry): entry is { type: 'document'; document: Document } & DocumentsListEntry =>
|
||||
entry.type === 'document' && !!entry.document
|
||||
)
|
||||
.map(entry => entry.document);
|
||||
}, [entries]);
|
||||
|
||||
// Layout System Initialization
|
||||
const layoutStore = useMemo(() => new LayoutStore(), []);
|
||||
const layoutRef = useRef<Map<string, LayoutCard>>(new Map());
|
||||
|
||||
// Update container size in store
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
layoutStore.setContainerSize(width, height);
|
||||
if (width > 0 && height > 0) {
|
||||
setHasContainerSize(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [layoutStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId && viewId) {
|
||||
// Clear store when switching views to prevent stale items
|
||||
layoutStore.clear();
|
||||
setIsLayoutLoaded(false);
|
||||
setIsLayoutReady(false); // Immediately hide cards during transition
|
||||
layoutStore.loadLayout(String(tenantId), viewId).then(() => {
|
||||
setIsLayoutLoaded(true);
|
||||
});
|
||||
} else {
|
||||
// No tenant/view ID means no saved layout to load - skip directly to loaded
|
||||
setIsLayoutLoaded(true);
|
||||
}
|
||||
}, [layoutStore, tenantId, viewId]);
|
||||
|
||||
const metadataMap = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
|
||||
|
||||
const ensureDocumentSize = useCallback((doc: Document): DocumentSizeInfo => {
|
||||
if (doc.id) {
|
||||
const meta = metadataMap.get(String(doc.id));
|
||||
if (meta && meta.width && meta.height)
|
||||
return { width: meta.width, height: meta.height, source: 'metadata' };
|
||||
}
|
||||
|
||||
return computeFallbackCardSize(doc, defaultCardSize);
|
||||
}, [metadataMap, defaultCardSize]);
|
||||
|
||||
// Synchronize LayoutStore with current items (Initialization & Cleanup)
|
||||
useEffect(() => {
|
||||
if (!hasContainerSize || !isLayoutLoaded) return;
|
||||
|
||||
// Cleanup Stale Items
|
||||
const currentIds = new Set(items.map((doc, index) => doc.id ? String(doc.id) : `temp-${index}`));
|
||||
for (const id of layoutStore.items.keys()) {
|
||||
if (!currentIds.has(id)) {
|
||||
layoutStore.unregister(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize / Update Items (Saved first, then others)
|
||||
const itemsWithSavedLayout: Document[] = [];
|
||||
const itemsWithoutSavedLayout: Document[] = [];
|
||||
|
||||
items.forEach(doc => {
|
||||
// If already initialized in store, we don't strictly need to prioritize it for collision,
|
||||
// but keeping the order ensures consistent behavior on re-runs.
|
||||
// However, usually we only care about *new* items for collision logic.
|
||||
if (doc.id && layoutStore.hasSavedLayout(String(doc.id))) {
|
||||
itemsWithSavedLayout.push(doc);
|
||||
} else {
|
||||
itemsWithoutSavedLayout.push(doc);
|
||||
}
|
||||
});
|
||||
|
||||
const initializeDoc = (doc: Document) => {
|
||||
const size = ensureDocumentSize(doc);
|
||||
const metadata = doc.current_version?.metadata as { page_count?: number } | undefined;
|
||||
const pageCount = metadata?.page_count ?? 1;
|
||||
|
||||
layoutStore.initialize(doc.id, null, {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
pageCount,
|
||||
maxSize: defaultCardSize
|
||||
});
|
||||
};
|
||||
|
||||
itemsWithSavedLayout.forEach(initializeDoc);
|
||||
itemsWithoutSavedLayout.forEach(initializeDoc);
|
||||
|
||||
// Save newly placed items
|
||||
if (itemsWithoutSavedLayout.length > 0) {
|
||||
void layoutStore.saveLayout();
|
||||
}
|
||||
|
||||
// Enforce constraints
|
||||
layoutStore.relayout();
|
||||
|
||||
setIsLayoutReady(true);
|
||||
}, [hasContainerSize, isLayoutLoaded, items, layoutStore, ensureDocumentSize, defaultCardSize]);
|
||||
|
||||
// Selection Context
|
||||
const {
|
||||
selectedDocumentIds,
|
||||
setSelectedEntries,
|
||||
clearSelection,
|
||||
} = useWorkspaceSelectionContext();
|
||||
|
||||
const handleSelectionChange = useCallback((ids: Identifier[]) => {
|
||||
// Sort IDs by Z-index (ascending) so the last item is the top-most
|
||||
const sortedIds = [...ids].sort((a, b) => {
|
||||
const cardA = layoutStore.items.get(String(a));
|
||||
const cardB = layoutStore.items.get(String(b));
|
||||
const zA = cardA ? cardA.z : -Infinity;
|
||||
const zB = cardB ? cardB.z : -Infinity;
|
||||
return zA - zB;
|
||||
});
|
||||
|
||||
if (setSelectedEntries) {
|
||||
const keys = sortedIds.map(id => createDocumentEntryKey(id));
|
||||
setSelectedEntries(keys);
|
||||
}
|
||||
onSelectionChange?.(sortedIds);
|
||||
}, [setSelectedEntries, onSelectionChange, layoutStore]);
|
||||
|
||||
const onClearSelection = useCallback(() => {
|
||||
clearSelection ? clearSelection() : handleSelectionChange([]);
|
||||
}, [clearSelection, handleSelectionChange]);
|
||||
|
||||
// Sync LayoutStore to layoutRef
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
layoutRef.current = layoutStore.items;
|
||||
};
|
||||
sync();
|
||||
}, [layoutStore.items]);
|
||||
|
||||
const handleShellKeyDown = useCallback(() => { }, []);
|
||||
const focusShell = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { scrollRef } = useDocumentsViewStateContext();
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
||||
// Handle events if the container or the shared scrollRef is focused
|
||||
// This allows unified handlers (which focus scrollRef) to work seamlessly with Desktop shortcuts
|
||||
const isTargetContainer = e.target === containerRef.current;
|
||||
const isTargetScrollRef = scrollRef && e.target === scrollRef.current;
|
||||
|
||||
if (!isTargetContainer && !isTargetScrollRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Space preview logic
|
||||
if ((e.code === 'Space' || e.code === 'Enter') && selectedDocumentIds.length > 0) {
|
||||
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||
const doc = items.find(i => String(i.id) === lastId);
|
||||
if (doc) {
|
||||
e.preventDefault();
|
||||
const target = e.code === 'Enter' ? 'inspect' : 'preview';
|
||||
openDocument(doc, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation logic
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
|
||||
const layoutItems = Array.from(layoutStore.items.values()) as LayoutCard[];
|
||||
if (layoutItems.length === 0) return;
|
||||
|
||||
let activeCard = null;
|
||||
if (selectedDocumentIds.length > 0) {
|
||||
// Use the last selected item as the anchor
|
||||
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||
activeCard = layoutStore.items.get(lastId);
|
||||
}
|
||||
|
||||
// If no selection or active card not found, select the top-most item
|
||||
if (!activeCard) {
|
||||
const topMost = layoutItems.reduce((prev, current) => (prev.z > current.z ? prev : current));
|
||||
handleSelectionChange([topMost.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cx = activeCard.centerX;
|
||||
const cy = activeCard.centerY;
|
||||
|
||||
let bestCandidate = null;
|
||||
let minScore = Infinity;
|
||||
|
||||
for (const candidate of layoutItems) {
|
||||
if (candidate.id === activeCard.id) continue;
|
||||
|
||||
const dx = candidate.centerX - cx;
|
||||
const dy = candidate.centerY - cy;
|
||||
|
||||
let valid = false;
|
||||
let primaryDist = 0;
|
||||
let offAxisDist = 0;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
if (dx > 0 && dx > Math.abs(dy)) {
|
||||
valid = true;
|
||||
primaryDist = dx;
|
||||
offAxisDist = Math.abs(dy);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (dx < 0 && -dx > Math.abs(dy)) {
|
||||
valid = true;
|
||||
primaryDist = -dx;
|
||||
offAxisDist = Math.abs(dy);
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (dy > 0 && dy > Math.abs(dx)) {
|
||||
valid = true;
|
||||
primaryDist = dy;
|
||||
offAxisDist = Math.abs(dx);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (dy < 0 && -dy > Math.abs(dx)) {
|
||||
valid = true;
|
||||
primaryDist = -dy;
|
||||
offAxisDist = Math.abs(dx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
// Weighted score: favor items closer in the primary direction, penalize off-axis
|
||||
// We use a multiplier for off-axis distance to prefer "straighter" lines
|
||||
// Reduced off-axis weight to favor directional distance (grid-like behavior)
|
||||
let score = primaryDist + (offAxisDist * 0.2);
|
||||
|
||||
// Z-Order Bonus: Subtract a small value based on Z-index to favor higher items
|
||||
// Assuming max Z is around 10000, 0.1 gives a max bonus of 1000, which is significant but less than primary distance usually
|
||||
score -= (candidate.z * 0.05);
|
||||
|
||||
// Obstruction Penalty: Check if the candidate is obstructed
|
||||
// If less than 5% is visible, treat as obstructed
|
||||
if (candidate.getVisibleFraction() < 0.05) {
|
||||
score += 5000; // Huge penalty for obstructed items
|
||||
}
|
||||
|
||||
if (score < minScore) {
|
||||
minScore = score;
|
||||
bestCandidate = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCandidate) {
|
||||
if (e.shiftKey) {
|
||||
// Additive selection
|
||||
const newSelection = new Set(selectedDocumentIds);
|
||||
newSelection.add(bestCandidate.id);
|
||||
handleSelectionChange(Array.from(newSelection));
|
||||
} else {
|
||||
// Replace selection
|
||||
handleSelectionChange([bestCandidate.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
}, [selectedDocumentIds, items, openDocument, layoutStore, handleSelectionChange, scrollRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="desk-shell"
|
||||
>
|
||||
<div
|
||||
className="desk-canvas"
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleShellKeyDown}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
// Register background pointer
|
||||
addPointer(e.pointerId);
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
|
||||
onClearSelection();
|
||||
focusShell();
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
removePointer(e.pointerId);
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
removePointer(e.pointerId);
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLayoutReady && items.map((doc, index) => {
|
||||
const docId = doc.id ? String(doc.id) : `temp-${index}`;
|
||||
const isSelected = selectedDocumentIds.includes(docId);
|
||||
|
||||
const layoutCard = layoutStore.items.get(docId);
|
||||
|
||||
if (!layoutCard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopDocumentCard
|
||||
key={docId}
|
||||
doc={doc}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
touchAction: 'none',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
shouldLoad={true}
|
||||
matchesFilter={true}
|
||||
|
||||
selected={isSelected}
|
||||
docTagTokens=""
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
onDocumentActivate={(_id, event) => {
|
||||
const isPreview = event && ((event as any).altKey || (event as any).button === 1);
|
||||
openDocument(doc, isPreview ? 'preview' : 'inspect');
|
||||
}}
|
||||
layoutCard={layoutCard}
|
||||
tagHandlers={tagHandlers}
|
||||
onSelect={(ids, extend = false) => {
|
||||
if (!extend) {
|
||||
handleSelectionChange(ids);
|
||||
} else {
|
||||
const newSelection = new Set(selectedDocumentIds);
|
||||
ids.forEach(id => newSelection.add(id));
|
||||
handleSelectionChange(Array.from(newSelection));
|
||||
}
|
||||
}}
|
||||
onDeselect={(ids) => {
|
||||
const newSelection = new Set(selectedDocumentIds);
|
||||
ids.forEach(id => newSelection.delete(id));
|
||||
handleSelectionChange(Array.from(newSelection));
|
||||
}}
|
||||
selection={selectedDocumentIds}
|
||||
requestCanvasFocus={focusShell}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = (props) => {
|
||||
return (
|
||||
<PointerTrackingProvider>
|
||||
<DesktopWorkspaceContent {...props} />
|
||||
</PointerTrackingProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopWorkspace;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Workspace cards, thumbnails, and hover controls */
|
||||
.desk-item__shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desk-item__card {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 12px 32px var(--shadow-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desk-item--swoop {
|
||||
transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.desk-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.desk-item__card--empty {
|
||||
box-shadow: 0 12px 32px var(--shadow-medium);
|
||||
background:
|
||||
radial-gradient(circle at 42% 38%, color-mix(in oklch, var(--surface-subtle) 75%, var(--selection) 25%), color-mix(in oklch, var(--surface-subtle) 85%, var(--selection) 15%) 70%),
|
||||
linear-gradient(135deg, color-mix(in oklch, var(--surface-subtle) 88%, var(--selection-soft) 12%) 0%, color-mix(in oklch, var(--surface-subtle) 65%, var(--shadow-faint) 35%) 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.desk-item__placeholder {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.desk-item__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.desk-item__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* Workspace items, states, and inline badges */
|
||||
.desk-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition:
|
||||
opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.55s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
filter: blur(0px) grayscale(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.desk-item__body {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desk-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.desk-item.is-tag-target .desk-item__card {
|
||||
outline: 0.35rem dashed var(--accent);
|
||||
outline-offset: 0.35rem;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.desk-item.is-tag-pending .desk-item__card {
|
||||
outline: 0.25rem solid var(--accent-outline);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.desk-item.is-filtered-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(18px) grayscale(100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.desk-item.is-selected {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.desk-item.is-selected .desk-item__card {
|
||||
box-shadow:
|
||||
0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent),
|
||||
0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent),
|
||||
0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent),
|
||||
0 10px 24px var(--shadow-medium);
|
||||
}
|
||||
|
||||
.desk-item.is-selected .desk-item__title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.desk-item__tags {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
transform-origin: top right;
|
||||
transform: translate(-0.5em, 0.5em);
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
|
||||
.desk-item__correspondents {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-start;
|
||||
transform-origin: bottom left;
|
||||
transform: translate(0.5em, -0.5em);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.desk-correspondent-chip {
|
||||
pointer-events: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
max-width: min(16rem, 80%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklch, var(--surface-subtle) 90%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.desk-correspondent-chip__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-chip--draggable {
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-chip--draggable.is-drag-hidden {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.desk-item__tags .tag-chip {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.desk-item__tags .tag-chip--tear-pending {
|
||||
opacity: 0.35;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Workspace layout & canvas scaffolding */
|
||||
.desk-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.desk-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 2 / -1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.desk-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.desk-canvas:focus,
|
||||
.desk-canvas:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body.desk-cursor-remove,
|
||||
body.desk-cursor-remove * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { DocumentId } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
import type { Asset, ThumbnailMetadata } from '../../types/assets';
|
||||
|
||||
interface PreviewMetadataEntry {
|
||||
docId: DocumentId;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type GetDocumentAsset = (doc: Document, type: string) => Asset | null;
|
||||
type EnsureAssetUrl = (docId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise<Asset | null>;
|
||||
|
||||
const usePreviewMetadata = (
|
||||
documents: Document[] | null,
|
||||
getDocumentAsset?: GetDocumentAsset,
|
||||
ensureAssetUrl?: EnsureAssetUrl,
|
||||
) => {
|
||||
const [metadataMap, setMetadataMap] = useState<Map<string, PreviewMetadataEntry>>(() => new Map());
|
||||
const failedIds = useRef(new Set<string>()); // Track failed fetches to prevent loops
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const docs = Array.isArray(documents) ? documents : [];
|
||||
if (!docs.length) {
|
||||
setMetadataMap(new Map());
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMetadataForDoc = async (doc: Document) => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docId = String(doc.id);
|
||||
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
|
||||
|
||||
let asset = resolveAsset('thumbnail');
|
||||
let metadata: Partial<ThumbnailMetadata> | null = (asset?.metadata as Partial<ThumbnailMetadata> | null) || null;
|
||||
|
||||
const hasDimensions = (meta: Partial<ThumbnailMetadata> | null): meta is ThumbnailMetadata =>
|
||||
typeof meta?.width === 'number' &&
|
||||
typeof meta?.height === 'number' &&
|
||||
meta.width > 0 &&
|
||||
meta.height > 0;
|
||||
|
||||
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
|
||||
// Skip if we already failed for this doc to avoid infinite loops
|
||||
if (!failedIds.current.has(docId)) {
|
||||
try {
|
||||
const ensured = await ensureAssetUrl(doc.id, asset);
|
||||
if (ensured) {
|
||||
asset = ensured;
|
||||
metadata = (asset?.metadata as Partial<ThumbnailMetadata> | null) || null;
|
||||
}
|
||||
|
||||
// If still no dimensions, mark as failed so we don't try again
|
||||
if (!hasDimensions(metadata)) {
|
||||
failedIds.current.add(docId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[desk] ensureDocumentSize metadata fetch failed', error);
|
||||
failedIds.current.add(docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDimensions(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
docId,
|
||||
width: Number(metadata.width),
|
||||
height: Number(metadata.height),
|
||||
};
|
||||
};
|
||||
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
const entries = await Promise.all(docs.map(fetchMetadataForDoc));
|
||||
if (!mounted || cancelled) {
|
||||
return;
|
||||
}
|
||||
const next = new Map();
|
||||
entries.forEach((entry) => {
|
||||
if (entry && entry.docId) {
|
||||
next.set(entry.docId, entry);
|
||||
}
|
||||
});
|
||||
if (!cancelled) {
|
||||
setMetadataMap(next);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mounted = false;
|
||||
};
|
||||
}, [documents, getDocumentAsset, ensureAssetUrl]);
|
||||
|
||||
return metadataMap;
|
||||
};
|
||||
|
||||
export default usePreviewMetadata;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { createSafeContext } from '../../utils/createSafeContext';
|
||||
|
||||
interface PointerTrackingContextType {
|
||||
activePointersRef: React.MutableRefObject<Map<number, string | undefined>>;
|
||||
addPointer: (id: number, cardId?: string) => void;
|
||||
removePointer: (id: number) => void;
|
||||
}
|
||||
|
||||
const [PointerTrackingContext, usePointerTracking] = createSafeContext<PointerTrackingContextType>('PointerTracking');
|
||||
|
||||
export const PointerTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const activePointersRef = useRef(new Map<number, string | undefined>());
|
||||
|
||||
const addPointer = useCallback((id: number, cardId?: string) => {
|
||||
activePointersRef.current.set(id, cardId);
|
||||
}, []);
|
||||
|
||||
const removePointer = useCallback((id: number) => {
|
||||
activePointersRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
return React.createElement(
|
||||
PointerTrackingContext.Provider,
|
||||
{ value: { activePointersRef, addPointer, removePointer } },
|
||||
children
|
||||
);
|
||||
};
|
||||
|
||||
export { usePointerTracking };
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { usePointerTracking } from './PointerTrackingContext';
|
||||
|
||||
import { LayoutCard } from '../logic/LayoutSystem';
|
||||
import { handleDragMove, handleDragEnd, handleDragStart, attachToDragGroup } from '../logic/CardDragLogic';
|
||||
|
||||
const DRAG_THRESHOLD = 3;
|
||||
|
||||
type PointerState = 'idle' | 'click' | 'drag';
|
||||
|
||||
export const useCardPointer = (
|
||||
card: LayoutCard,
|
||||
isSelected: boolean,
|
||||
selection: string[],
|
||||
onSelect: (ids: string[], extend?: boolean) => void,
|
||||
onDeselect: (ids: string[]) => void,
|
||||
onDocumentActivate?: (id: string, event?: React.PointerEvent) => void,
|
||||
requestCanvasFocus?: () => void
|
||||
) => {
|
||||
const [state, setState] = React.useState<PointerState>('idle');
|
||||
const initialPosition = useRef<{ x: number, y: number } | null>(null);
|
||||
const lastPosition = useRef<{ x: number, y: number } | null>(null);
|
||||
const lastClickTime = useRef<number>(0);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { activePointersRef, addPointer, removePointer } = usePointerTracking();
|
||||
|
||||
const updateState = useCallback((e: React.PointerEvent) => {
|
||||
if (state === 'click' && initialPosition.current) {
|
||||
const dx = e.clientX - initialPosition.current.x;
|
||||
const dy = e.clientY - initialPosition.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > DRAG_THRESHOLD) {
|
||||
setState('drag-start');
|
||||
}
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
// Allow left (0) and middle (1) click
|
||||
if (e.button !== 0 && e.button !== 1) return;
|
||||
|
||||
// Ignore interactions on interactive child elements (tags, inputs, buttons, etc.)
|
||||
// We want these elements to handle their own pointer/drag events.
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
|
||||
// Register pointer with card ID
|
||||
addPointer(e.pointerId, card.id);
|
||||
|
||||
requestCanvasFocus?.();
|
||||
|
||||
setState('click');
|
||||
initialPosition.current = { x: e.clientX, y: e.clientY };
|
||||
lastPosition.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
// Long press detection for touch devices
|
||||
if (e.pointerType === 'touch') {
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
// Select stack
|
||||
const stackIds = card.store.getStackBelow(card);
|
||||
const idsToAdd = new Set<string>();
|
||||
|
||||
if (!isSelected) idsToAdd.add(card.id);
|
||||
stackIds.forEach(id => idsToAdd.add(id));
|
||||
|
||||
// Only select what isn't already selected
|
||||
const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id));
|
||||
|
||||
if (unselectedIdsToAdd.length > 0) {
|
||||
onSelect(unselectedIdsToAdd, true);
|
||||
|
||||
const isMultiTouch = activePointersRef.current.size > 1;
|
||||
if (isMultiTouch) {
|
||||
// Find an existing drag group to attach to
|
||||
let targetLeaderId: string | null = null;
|
||||
let targetPointerId: number | null = null;
|
||||
|
||||
for (const [ptrId, cId] of activePointersRef.current.entries()) {
|
||||
if (ptrId === e.pointerId) continue; // Skip self
|
||||
if (cId) {
|
||||
const c = card.store.items.get(cId);
|
||||
if (c && c.isDragging) {
|
||||
targetLeaderId = cId;
|
||||
targetPointerId = c.physics.dragPointerId; // Use the pointer driving that card
|
||||
break; // Attach to the first found drag group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLeaderId && targetPointerId !== null) {
|
||||
attachToDragGroup(card.store, unselectedIdsToAdd, targetLeaderId, targetPointerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
}
|
||||
}, 500); // 500ms long press
|
||||
}
|
||||
}, [card, isSelected, selection, onSelect, addPointer, activePointersRef, requestCanvasFocus]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
// Ignore interactions on interactive child elements
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Check for multi-touch (more than 1 active pointer implies we should add to selection)
|
||||
// We check > 1 because the current pointer is already added
|
||||
const isMultiTouch = activePointersRef.current.size > 1;
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch;
|
||||
|
||||
updateState(e);
|
||||
|
||||
// Cancel long press if moved
|
||||
if (state === 'click' && initialPosition.current) {
|
||||
const dx = e.clientX - initialPosition.current.x;
|
||||
const dy = e.clientY - initialPosition.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_THRESHOLD && longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'drag-start') {
|
||||
let effectiveSelection = selection;
|
||||
|
||||
if (!hasModifier) {
|
||||
if (!isSelected) {
|
||||
effectiveSelection = [card.id];
|
||||
onSelect([card.id], false);
|
||||
}
|
||||
} else {
|
||||
// Modifier pressed: Add card and stack to selection
|
||||
const stackIds = card.store.getStackBelow(card);
|
||||
const idsToAdd = new Set<string>();
|
||||
|
||||
if (!isSelected) idsToAdd.add(card.id);
|
||||
stackIds.forEach(id => idsToAdd.add(id));
|
||||
|
||||
// Only select what isn't already selected to avoid toggling off
|
||||
const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id));
|
||||
|
||||
if (unselectedIdsToAdd.length > 0) {
|
||||
onSelect(unselectedIdsToAdd, true);
|
||||
effectiveSelection = [...selection, ...unselectedIdsToAdd];
|
||||
}
|
||||
}
|
||||
|
||||
card.store.bringToFront(effectiveSelection);
|
||||
|
||||
setState('drag');
|
||||
|
||||
// Start the drag for THIS pointer
|
||||
if (initialPosition.current) {
|
||||
const rect = card.ref.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
const leadingCardId = card.id;
|
||||
|
||||
const offset = {
|
||||
x: initialPosition.current.x - centerX,
|
||||
y: initialPosition.current.y - centerY
|
||||
};
|
||||
|
||||
handleDragStart(card.store, effectiveSelection, leadingCardId, offset, e.pointerId);
|
||||
// Reset lastPosition to current pointer to avoid jump on first move
|
||||
lastPosition.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'drag' && lastPosition.current) {
|
||||
const delta = {
|
||||
x: e.clientX - lastPosition.current.x,
|
||||
y: e.clientY - lastPosition.current.y
|
||||
};
|
||||
|
||||
if (delta.x !== 0 || delta.y !== 0) {
|
||||
handleDragMove(card.store, selection, delta, e.pointerId);
|
||||
lastPosition.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
}, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
// Ignore interactions on interactive child elements
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]');
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
|
||||
// Check for multi-touch before removing the pointer
|
||||
const isMultiTouch = activePointersRef.current.size > 1;
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch;
|
||||
|
||||
// Unregister pointer
|
||||
removePointer(e.pointerId);
|
||||
|
||||
updateState(e);
|
||||
|
||||
if (state === 'drag' || state === 'drag-start') {
|
||||
handleDragEnd(card.store, selection, e.pointerId);
|
||||
} else if (state === 'click') {
|
||||
const now = Date.now();
|
||||
if (now - lastClickTime.current < 300) {
|
||||
onDocumentActivate?.(card.id, e);
|
||||
}
|
||||
lastClickTime.current = now;
|
||||
|
||||
if (isSelected) {
|
||||
if (hasModifier) {
|
||||
onDeselect([card.id]);
|
||||
}
|
||||
} else {
|
||||
onSelect([card.id], hasModifier);
|
||||
|
||||
if (!hasModifier) {
|
||||
card.bringToFront();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState('idle');
|
||||
initialPosition.current = null;
|
||||
lastPosition.current = null;
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
}, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]);
|
||||
|
||||
const onPointerCancel = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
|
||||
// Ensure we clean of any drags associated with this pointer
|
||||
handleDragEnd(card.store, selection, e.pointerId);
|
||||
|
||||
removePointer(e.pointerId);
|
||||
setState('idle');
|
||||
initialPosition.current = null;
|
||||
lastPosition.current = null;
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
}, [card, selection, removePointer]);
|
||||
|
||||
return {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LayoutStore } from './LayoutSystem';
|
||||
|
||||
export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }, pointerId: number) => {
|
||||
const leadingCard = store.items.get(leadingId);
|
||||
if (!leadingCard) return;
|
||||
|
||||
// 1. Begin drag on the leader (clears old followers)
|
||||
leadingCard.physics.beginDrag(offset, pointerId);
|
||||
|
||||
// 2. Snap all followers to leader's center
|
||||
// 3. Attach them as followers to the leader's physics
|
||||
selection.forEach(id => {
|
||||
if (id === leadingId) return;
|
||||
const card = store.items.get(id);
|
||||
if (card) {
|
||||
// If card is already dragging by another pointer, skip it
|
||||
if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return;
|
||||
|
||||
const leadingCenterX = leadingCard.x + leadingCard.width / 2;
|
||||
const leadingCenterY = leadingCard.y + leadingCard.height / 2;
|
||||
|
||||
const targetX = leadingCenterX - card.width / 2;
|
||||
const targetY = leadingCenterY - card.height / 2;
|
||||
|
||||
card.snapTo(targetX, targetY);
|
||||
|
||||
// Stop any existing physics on the follower
|
||||
card.physics.stop();
|
||||
|
||||
// Attach as follower
|
||||
leadingCard.physics.addFollower(card);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, _pointerId: number) => {
|
||||
const leadingCard = store.items.get(leadingId);
|
||||
if (!leadingCard || !leadingCard.physics.isDragging) return;
|
||||
|
||||
selection.forEach(id => {
|
||||
if (id === leadingId) return;
|
||||
const card = store.items.get(id);
|
||||
|
||||
// If card exists and is not already dragging, attach it
|
||||
if (card && !card.physics.isDragging) {
|
||||
const leadingCenterX = leadingCard.x + leadingCard.width / 2;
|
||||
const leadingCenterY = leadingCard.y + leadingCard.height / 2;
|
||||
|
||||
const targetX = leadingCenterX - card.width / 2;
|
||||
const targetY = leadingCenterY - card.height / 2;
|
||||
|
||||
card.snapTo(targetX, targetY);
|
||||
|
||||
// Stop any existing physics
|
||||
card.physics.stop();
|
||||
|
||||
// Attach as follower
|
||||
leadingCard.physics.addFollower(card);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const handleDragMove = (store: LayoutStore, _selection: string[], delta: { x: number, y: number }, pointerId: number) => {
|
||||
for (const card of store.items.values()) {
|
||||
if (card.physics.isDragging && card.physics.dragPointerId === pointerId) {
|
||||
card.physics.continueDrag(delta);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => {
|
||||
for (const card of store.items.values()) {
|
||||
if (card.physics.dragPointerId === pointerId) {
|
||||
card.physics.finishDrag();
|
||||
}
|
||||
}
|
||||
store.saveLayout();
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import { LayoutCard } from './LayoutSystem';
|
||||
|
||||
export class CardPhysics {
|
||||
private velocity: { x: number, y: number, rotation: number } = { x: 0, y: 0, rotation: 0 };
|
||||
private _isDragging: boolean = false;
|
||||
public dragPointerId: number | null = null;
|
||||
private pendingDelta: { x: number, y: number } = { x: 0, y: 0 };
|
||||
|
||||
public mass: number = 30;
|
||||
private baseMass: number = 30;
|
||||
private massScale: number = 1;
|
||||
private angularVelocity: number = 0;
|
||||
private lastTimestamp: number = 0;
|
||||
|
||||
private dragOffset: { x: number, y: number } | null = null;
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this._isDragging;
|
||||
}
|
||||
|
||||
private physicsRafId: number | null = null;
|
||||
private lastTickTime: number = 0;
|
||||
private card: LayoutCard;
|
||||
|
||||
constructor(card: LayoutCard) {
|
||||
this.card = card;
|
||||
this.updateMass(card.pageCount);
|
||||
}
|
||||
|
||||
updateMass(pageCount: number) {
|
||||
const pages = Math.max(1, pageCount);
|
||||
this.baseMass = 30 + 5 * pages;
|
||||
this.mass = this.baseMass;
|
||||
this.updateMassScale();
|
||||
}
|
||||
|
||||
private updateMassScale() {
|
||||
this.massScale = Math.max(this.mass / 30, 1);
|
||||
}
|
||||
|
||||
private normalizeAngle(angle: number): number {
|
||||
let a = angle % 360;
|
||||
if (a > 180) a -= 360;
|
||||
if (a <= -180) a += 360;
|
||||
return a;
|
||||
}
|
||||
|
||||
beginDrag(offset: { x: number, y: number }, pointerId: number) {
|
||||
// If I am a follower of someone else, detach first!
|
||||
if (this.leader) {
|
||||
this.leader.removeFollower(this.card);
|
||||
}
|
||||
|
||||
// Ensure I don't have stale followers from a previous session
|
||||
this.stopPhysicsLoop();
|
||||
|
||||
this._isDragging = true;
|
||||
this.dragPointerId = pointerId;
|
||||
|
||||
// Store the offset from center where we grabbed the card
|
||||
// Convert world offset to local offset (rotate by -rotation)
|
||||
const rad = -this.card.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
this.dragOffset = {
|
||||
x: offset.x * cos - offset.y * sin,
|
||||
y: offset.x * sin + offset.y * cos
|
||||
};
|
||||
|
||||
this.angularVelocity = 0;
|
||||
this.startPhysicsLoop();
|
||||
}
|
||||
|
||||
continueDrag(delta: { x: number, y: number }) {
|
||||
this.pendingDelta.x += delta.x;
|
||||
this.pendingDelta.y += delta.y;
|
||||
}
|
||||
|
||||
finishDrag() {
|
||||
this._isDragging = false;
|
||||
this.dragPointerId = null;
|
||||
// Keep dragOffset for inertia pivot correction
|
||||
this.lastTimestamp = performance.now();
|
||||
this.angularVelocity = 0; // Kill momentum on release
|
||||
}
|
||||
|
||||
private startPhysicsLoop() {
|
||||
if (this.physicsRafId) return;
|
||||
this.lastTickTime = performance.now();
|
||||
this.physicsRafId = requestAnimationFrame(this.physicsTick);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.stopPhysicsLoop();
|
||||
}
|
||||
|
||||
private stopPhysicsLoop() {
|
||||
if (this.physicsRafId) {
|
||||
cancelAnimationFrame(this.physicsRafId);
|
||||
this.physicsRafId = null;
|
||||
}
|
||||
this.dragOffset = null;
|
||||
this.clearFollowers();
|
||||
}
|
||||
|
||||
private physicsTick = (time: number) => {
|
||||
const rawDt = (time - this.lastTickTime) / 1000;
|
||||
const dt = Math.max(1 / 120, Math.min(rawDt, 1 / 20));
|
||||
this.lastTickTime = time;
|
||||
|
||||
this.updatePhysics(dt);
|
||||
|
||||
if (this.physicsRafId) {
|
||||
this.physicsRafId = requestAnimationFrame(this.physicsTick);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly ROTATION_LIMIT = 5;
|
||||
private minLimit: number = -5;
|
||||
private maxLimit: number = 5;
|
||||
|
||||
private followers: { card: LayoutCard, offsetRotation: number }[] = [];
|
||||
public leader: CardPhysics | null = null;
|
||||
|
||||
addFollower(card: LayoutCard) {
|
||||
// Calculate relative rotation
|
||||
// follower = leader + offset => offset = follower - leader
|
||||
const offset = this.normalizeAngle(card.rotation - this.card.rotation);
|
||||
this.followers.push({ card, offsetRotation: offset });
|
||||
|
||||
// Set back-reference
|
||||
card.physics.leader = this;
|
||||
|
||||
// Add follower mass to leader
|
||||
this.mass += card.physics.mass;
|
||||
this.updateMassScale();
|
||||
|
||||
// Constrain leader limits to ensure follower stays within [-5, 5]
|
||||
// But clamp the result to never exceed the global limits [-5, 5]
|
||||
// This prevents the leader from being forced into extreme angles by far-away followers
|
||||
const calculatedMin = -this.ROTATION_LIMIT - offset;
|
||||
const calculatedMax = this.ROTATION_LIMIT - offset;
|
||||
|
||||
this.minLimit = Math.max(this.minLimit, Math.min(this.ROTATION_LIMIT, Math.max(-this.ROTATION_LIMIT, calculatedMin)));
|
||||
this.maxLimit = Math.min(this.maxLimit, Math.max(-this.ROTATION_LIMIT, Math.min(this.ROTATION_LIMIT, calculatedMax)));
|
||||
|
||||
// Safety: If limits invert (min > max), prioritize keeping leader near 0
|
||||
if (this.minLimit > this.maxLimit) {
|
||||
this.minLimit = -this.ROTATION_LIMIT;
|
||||
this.maxLimit = this.ROTATION_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
removeFollower(card: LayoutCard) {
|
||||
const index = this.followers.findIndex(f => f.card === card);
|
||||
if (index !== -1) {
|
||||
const follower = this.followers[index];
|
||||
follower.card.physics.leader = null;
|
||||
this.followers.splice(index, 1);
|
||||
|
||||
// Recalculate mass and limits
|
||||
this.recalculateStackProperties();
|
||||
}
|
||||
}
|
||||
|
||||
clearFollowers() {
|
||||
// Clear back-references
|
||||
this.followers.forEach(f => {
|
||||
f.card.physics.leader = null;
|
||||
});
|
||||
this.followers = [];
|
||||
this.recalculateStackProperties();
|
||||
}
|
||||
|
||||
private recalculateStackProperties() {
|
||||
this.mass = this.baseMass;
|
||||
this.minLimit = -this.ROTATION_LIMIT;
|
||||
this.maxLimit = this.ROTATION_LIMIT;
|
||||
|
||||
for (const f of this.followers) {
|
||||
this.mass += f.card.physics.mass;
|
||||
|
||||
// Re-apply limits
|
||||
const offset = f.offsetRotation;
|
||||
const calculatedMin = -this.ROTATION_LIMIT - offset;
|
||||
const calculatedMax = this.ROTATION_LIMIT - offset;
|
||||
|
||||
this.minLimit = Math.max(this.minLimit, Math.min(this.ROTATION_LIMIT, Math.max(-this.ROTATION_LIMIT, calculatedMin)));
|
||||
this.maxLimit = Math.min(this.maxLimit, Math.max(-this.ROTATION_LIMIT, Math.min(this.ROTATION_LIMIT, calculatedMax)));
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if (this.minLimit > this.maxLimit) {
|
||||
this.minLimit = -this.ROTATION_LIMIT;
|
||||
this.maxLimit = this.ROTATION_LIMIT;
|
||||
}
|
||||
|
||||
this.updateMassScale();
|
||||
}
|
||||
|
||||
private updatePhysics(dt: number) {
|
||||
const dx = this.pendingDelta.x;
|
||||
const dy = this.pendingDelta.y;
|
||||
|
||||
this.pendingDelta = { x: 0, y: 0 };
|
||||
|
||||
const vx = dx / dt;
|
||||
const vy = dy / dt;
|
||||
|
||||
// 1. Update Position (Direct 1:1 movement)
|
||||
const newX = this.card.x + dx;
|
||||
const newY = this.card.y + dy;
|
||||
const constrained = this.card.getConstrainedPosition(newX, newY);
|
||||
|
||||
// 2. Calculate Torque & Forces
|
||||
let torque = 0;
|
||||
let recoveryTorque = 0;
|
||||
let isRecovering = false;
|
||||
|
||||
// Drag Torque
|
||||
if (this.dragOffset && this._isDragging) {
|
||||
const rad = this.card.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const worldLeverX = this.dragOffset.x * cos - this.dragOffset.y * sin;
|
||||
const worldLeverY = this.dragOffset.x * sin + this.dragOffset.y * cos;
|
||||
|
||||
torque = worldLeverX * vy - worldLeverY * vx;
|
||||
}
|
||||
|
||||
// Recovery Torque
|
||||
const normRot = this.normalizeAngle(this.card.rotation);
|
||||
if (normRot > this.maxLimit) {
|
||||
recoveryTorque = (this.maxLimit - normRot) * 2500;
|
||||
isRecovering = true;
|
||||
} else if (normRot < this.minLimit) {
|
||||
recoveryTorque = (this.minLimit - normRot) * 2500;
|
||||
isRecovering = true;
|
||||
}
|
||||
|
||||
const totalTorque = torque + recoveryTorque;
|
||||
const alpha = (totalTorque * 0.05) / this.massScale;
|
||||
this.angularVelocity += alpha * dt;
|
||||
|
||||
// Friction
|
||||
this.angularVelocity *= 0.85;
|
||||
if (isRecovering) {
|
||||
this.angularVelocity *= 0.6;
|
||||
}
|
||||
|
||||
// Deadzone
|
||||
if (Math.abs(this.angularVelocity) < 1) {
|
||||
this.angularVelocity = 0;
|
||||
}
|
||||
|
||||
// 3. Update Rotation
|
||||
let newRot = this.card.rotation + this.angularVelocity * dt;
|
||||
|
||||
// Ratchet clamping
|
||||
const newNormRot = this.normalizeAngle(newRot);
|
||||
|
||||
if (newNormRot > this.maxLimit) {
|
||||
// If moving further past max, clamp
|
||||
if (newNormRot > normRot) {
|
||||
newRot = this.card.rotation + (this.maxLimit - normRot);
|
||||
this.angularVelocity = 0;
|
||||
}
|
||||
} else if (newNormRot < this.minLimit) {
|
||||
// If moving further past min, clamp
|
||||
if (newNormRot < normRot) {
|
||||
newRot = this.card.rotation + (this.minLimit - normRot);
|
||||
this.angularVelocity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pivot Correction
|
||||
let correctionX = 0;
|
||||
let correctionY = 0;
|
||||
|
||||
if (this.dragOffset) {
|
||||
const oldRad = this.card.rotation * Math.PI / 180;
|
||||
const oldCos = Math.cos(oldRad);
|
||||
const oldSin = Math.sin(oldRad);
|
||||
|
||||
const newRad = newRot * Math.PI / 180;
|
||||
const newCos = Math.cos(newRad);
|
||||
const newSin = Math.sin(newRad);
|
||||
|
||||
const oldLeverX = this.dragOffset.x * oldCos - this.dragOffset.y * oldSin;
|
||||
const oldLeverY = this.dragOffset.x * oldSin + this.dragOffset.y * oldCos;
|
||||
|
||||
const newLeverX = this.dragOffset.x * newCos - this.dragOffset.y * newSin;
|
||||
const newLeverY = this.dragOffset.x * newSin + this.dragOffset.y * newCos;
|
||||
|
||||
correctionX = oldLeverX - newLeverX;
|
||||
correctionY = oldLeverY - newLeverY;
|
||||
}
|
||||
|
||||
// Stop Condition
|
||||
if (!this._isDragging) {
|
||||
const currentNormRot = this.normalizeAngle(this.card.rotation);
|
||||
const isOutside = currentNormRot > this.maxLimit || currentNormRot < this.minLimit;
|
||||
|
||||
if (isOutside) {
|
||||
const targetAngle = currentNormRot > this.maxLimit ? this.maxLimit : this.minLimit;
|
||||
const dist = Math.abs(this.normalizeAngle(currentNormRot - targetAngle));
|
||||
|
||||
if (Math.abs(this.angularVelocity) < 0.5 && dist < 0.1) {
|
||||
this.card.update({ rotation: targetAngle }, { markDirty: true });
|
||||
this.angularVelocity = 0;
|
||||
this.stopPhysicsLoop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Inside range - just stop if slow
|
||||
if (Math.abs(this.angularVelocity) < 0.5) {
|
||||
this.angularVelocity = 0;
|
||||
this.stopPhysicsLoop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Apply to Leader
|
||||
const finalX = constrained.x + correctionX;
|
||||
const finalY = constrained.y + correctionY;
|
||||
const finalConstrained = this.card.getConstrainedPosition(finalX, finalY);
|
||||
|
||||
this.card.update({
|
||||
x: finalConstrained.x,
|
||||
y: finalConstrained.y,
|
||||
rotation: newRot
|
||||
}, { markDirty: true });
|
||||
|
||||
// 6. Apply to Followers
|
||||
for (const follower of this.followers) {
|
||||
// Followers match leader's position exactly (center aligned)
|
||||
// But we need to account for their own dimensions if we want center-to-center alignment
|
||||
// The LayoutCard.x/y is top-left.
|
||||
// Leader Center: finalConstrained.x + leader.width/2, finalConstrained.y + leader.height/2
|
||||
|
||||
const leaderCenterX = finalConstrained.x + this.card.width / 2;
|
||||
const leaderCenterY = finalConstrained.y + this.card.height / 2;
|
||||
|
||||
const followerX = leaderCenterX - follower.card.width / 2;
|
||||
const followerY = leaderCenterY - follower.card.height / 2;
|
||||
|
||||
const followerRot = newRot + follower.offsetRotation;
|
||||
|
||||
follower.card.update({
|
||||
x: followerX,
|
||||
y: followerY,
|
||||
rotation: followerRot
|
||||
}, { markDirty: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from '../utils/layoutUtils';
|
||||
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||
import { CardPhysics } from './CardPhysics';
|
||||
|
||||
interface LayoutCardState {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
rotation: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export class LayoutCard implements LayoutCardState {
|
||||
id: string;
|
||||
x: number = 0;
|
||||
y: number = 0;
|
||||
z: number = 0;
|
||||
rotation: number = 0;
|
||||
width: number = 0;
|
||||
height: number = 0;
|
||||
pageCount: number = 1;
|
||||
ref: HTMLElement | null = null;
|
||||
|
||||
private _innerRadius: number = 0;
|
||||
private _outerRadius: number = 0;
|
||||
private _centerX: number = 0;
|
||||
private _centerY: number = 0;
|
||||
public store: LayoutStore;
|
||||
public physics: CardPhysics;
|
||||
public intendedX: number = 0;
|
||||
public intendedY: number = 0;
|
||||
public isDirty: boolean = false;
|
||||
|
||||
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
||||
this.id = id;
|
||||
this.store = store;
|
||||
Object.assign(this, initialData);
|
||||
this.physics = new CardPhysics(this);
|
||||
this.intendedX = this.x;
|
||||
this.intendedY = this.y;
|
||||
this.ref = ref;
|
||||
this.recalculateRadii();
|
||||
this.recalculateCenters();
|
||||
}
|
||||
|
||||
setRef(ref: HTMLElement | null) {
|
||||
this.ref = ref;
|
||||
this.applyTransform();
|
||||
}
|
||||
|
||||
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
|
||||
const rad = (this.rotation * Math.PI) / 180;
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
|
||||
const rotatedWidth = this.width * cos + this.height * sin;
|
||||
const rotatedHeight = this.width * sin + this.height * cos;
|
||||
|
||||
const minX = CONTAINER_PADDING + (rotatedWidth - this.width) / 2;
|
||||
const maxX = this.store.containerWidth - CONTAINER_PADDING - this.width - (rotatedWidth - this.width) / 2;
|
||||
|
||||
const minY = CONTAINER_PADDING + (rotatedHeight - this.height) / 2;
|
||||
const maxY = this.store.containerHeight - CONTAINER_PADDING - this.height - (rotatedHeight - this.height) / 2;
|
||||
|
||||
const newX = Math.max(minX, Math.min(x, maxX));
|
||||
const newY = Math.max(minY, Math.min(y, maxY));
|
||||
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
|
||||
|
||||
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
|
||||
Object.assign(this, changes);
|
||||
|
||||
if (!options.isConstraintUpdate) {
|
||||
if (changes.x !== undefined) this.intendedX = changes.x;
|
||||
if (changes.y !== undefined) this.intendedY = changes.y;
|
||||
}
|
||||
|
||||
if (changes.width !== undefined || changes.height !== undefined) {
|
||||
this.recalculateRadii();
|
||||
}
|
||||
|
||||
if (changes.x !== undefined || changes.y !== undefined || changes.width !== undefined || changes.height !== undefined) {
|
||||
this.recalculateCenters();
|
||||
}
|
||||
|
||||
if (changes.pageCount !== undefined) {
|
||||
this.physics.updateMass(changes.pageCount);
|
||||
}
|
||||
|
||||
if (options.markDirty) {
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
this.applyTransform();
|
||||
}
|
||||
|
||||
isUnobstructed(): boolean {
|
||||
for (const other of this.store.items.values()) {
|
||||
if (other.id === this.id) continue;
|
||||
if (other.z <= this.z) continue;
|
||||
|
||||
const dx = other.x - this.x;
|
||||
const dy = other.y - this.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Broad phase: check outer radii
|
||||
if (distance < this.outerRadius + other.outerRadius) {
|
||||
// Narrow phase: SAT intersection test
|
||||
if (this.intersects(other)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bringToFront() {
|
||||
this.z = this.store.zCounter++;
|
||||
}
|
||||
|
||||
private getVertices(): { x: number; y: number }[] {
|
||||
const rad = (this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const hw = this.width / 2;
|
||||
const hh = this.height / 2;
|
||||
|
||||
// Corners relative to center, then rotated, then translated
|
||||
// (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)
|
||||
const corners = [
|
||||
{ x: -hw, y: -hh },
|
||||
{ x: hw, y: -hh },
|
||||
{ x: hw, y: hh },
|
||||
{ x: -hw, y: hh }
|
||||
];
|
||||
|
||||
return corners.map(p => ({
|
||||
x: (p.x * cos - p.y * sin) + this._centerX,
|
||||
y: (p.x * sin + p.y * cos) + this._centerY
|
||||
}));
|
||||
}
|
||||
|
||||
containsPoint(x: number, y: number): boolean {
|
||||
// Translate point to local space relative to center
|
||||
const dx = x - this._centerX;
|
||||
const dy = y - this._centerY;
|
||||
|
||||
// Rotate point by -rotation to align with AABB
|
||||
const rad = (-this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
|
||||
const hw = this.width / 2;
|
||||
const hh = this.height / 2;
|
||||
|
||||
return localX >= -hw && localX <= hw && localY >= -hh && localY <= hh;
|
||||
}
|
||||
|
||||
getVisibleFraction(): number {
|
||||
const samplesX = 4;
|
||||
const samplesY = 4;
|
||||
const totalSamples = samplesX * samplesY;
|
||||
let visibleSamples = 0;
|
||||
|
||||
// Get potential occluders (higher Z-index)
|
||||
const occluders = Array.from(this.store.items.values()).filter(other =>
|
||||
other.id !== this.id && other.z > this.z
|
||||
);
|
||||
|
||||
if (occluders.length === 0) return 1.0;
|
||||
|
||||
const rad = (this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const hw = this.width / 2;
|
||||
const hh = this.height / 2;
|
||||
|
||||
// Sample points across the card surface
|
||||
for (let i = 0; i < samplesX; i++) {
|
||||
for (let j = 0; j < samplesY; j++) {
|
||||
// Normalized coordinates [-1, 1]
|
||||
const nx = (i / (samplesX - 1)) * 2 - 1;
|
||||
const ny = (j / (samplesY - 1)) * 2 - 1;
|
||||
|
||||
// Local coordinates
|
||||
const lx = nx * hw * 0.9; // 0.9 to avoid edge cases
|
||||
const ly = ny * hh * 0.9;
|
||||
|
||||
// World coordinates
|
||||
const wx = (lx * cos - ly * sin) + this._centerX;
|
||||
const wy = (lx * sin + ly * cos) + this._centerY;
|
||||
|
||||
// Check occlusion
|
||||
let isOccluded = false;
|
||||
for (const occluder of occluders) {
|
||||
if (occluder.containsPoint(wx, wy)) {
|
||||
isOccluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOccluded) {
|
||||
visibleSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleSamples / totalSamples;
|
||||
}
|
||||
|
||||
private getAxes(): { x: number; y: number }[] {
|
||||
const rad = (this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Normals of the edges (local x and y axes)
|
||||
return [
|
||||
{ x: cos, y: sin },
|
||||
{ x: -sin, y: cos }
|
||||
];
|
||||
}
|
||||
|
||||
private intersects(other: LayoutCard): boolean {
|
||||
const verticesA = this.getVertices();
|
||||
const verticesB = other.getVertices();
|
||||
const axes = [...this.getAxes(), ...other.getAxes()];
|
||||
|
||||
for (const axis of axes) {
|
||||
const pA = this.project(verticesA, axis);
|
||||
const pB = this.project(verticesB, axis);
|
||||
|
||||
if (pA.max < pB.min || pB.max < pA.min) {
|
||||
return false; // Gap found, no intersection
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private project(vertices: { x: number; y: number }[], axis: { x: number; y: number }) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const v of vertices) {
|
||||
const dot = v.x * axis.x + v.y * axis.y;
|
||||
if (dot < min) min = dot;
|
||||
if (dot > max) max = dot;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
private recalculateRadii() {
|
||||
this._innerRadius = Math.min(this.width, this.height) / 2;
|
||||
this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2;
|
||||
}
|
||||
|
||||
private recalculateCenters() {
|
||||
this._centerX = this.x + this.width / 2;
|
||||
this._centerY = this.y + this.height / 2;
|
||||
}
|
||||
|
||||
private rafId: number | null = null;
|
||||
|
||||
private applyTransform() {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
if (this.ref) {
|
||||
this.ref.style.transform =
|
||||
`translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
||||
this.ref.style.zIndex = String(this.z);
|
||||
this.ref.style.width = `${this.width}px`;
|
||||
this.ref.style.height = `${this.height}px`;
|
||||
}
|
||||
this.rafId = null;
|
||||
});
|
||||
}
|
||||
|
||||
snapTo(x: number, y: number) {
|
||||
this.update({ x, y }, { markDirty: true });
|
||||
|
||||
if (!this.ref)
|
||||
return;
|
||||
|
||||
this.ref.classList.add('desk-item--swoop');
|
||||
this.ref.style.transform = `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
|
||||
|
||||
const cleanup = () => {
|
||||
if (this.ref) {
|
||||
this.ref.classList.remove('desk-item--swoop');
|
||||
}
|
||||
};
|
||||
|
||||
this.ref.addEventListener('transitionend', cleanup, { once: true });
|
||||
|
||||
// Safety timeout in case transitionend doesn't fire (e.g. element removed)
|
||||
setTimeout(cleanup, 350);
|
||||
}
|
||||
|
||||
toSnapshot(): LayoutCardState {
|
||||
return {
|
||||
id: this.id,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
z: this.z,
|
||||
rotation: this.rotation,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
pageCount: this.pageCount
|
||||
};
|
||||
}
|
||||
|
||||
get innerRadius(): number {
|
||||
return this._innerRadius;
|
||||
}
|
||||
|
||||
get outerRadius(): number {
|
||||
return this._outerRadius;
|
||||
}
|
||||
|
||||
get centerX(): number {
|
||||
return this._centerX;
|
||||
}
|
||||
|
||||
get centerY(): number {
|
||||
return this._centerY;
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this.physics.isDragging;
|
||||
}
|
||||
}
|
||||
|
||||
export class LayoutStore {
|
||||
items = new Map<string, LayoutCard>();
|
||||
zCounter = 100;
|
||||
containerWidth: number = 0;
|
||||
containerHeight: number = 0;
|
||||
|
||||
private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>();
|
||||
private tenantId: string | null = null;
|
||||
private viewId: string | null = null;
|
||||
|
||||
initialize(id: string, ref: HTMLElement | null, config: {
|
||||
width: number;
|
||||
height: number;
|
||||
pageCount: number;
|
||||
maxSize?: number;
|
||||
}) {
|
||||
let card = this.items.get(id);
|
||||
|
||||
const { width, height } = constrainDimensions(
|
||||
config.width,
|
||||
config.height,
|
||||
config.maxSize || 320
|
||||
);
|
||||
|
||||
if (!card) {
|
||||
// Check for saved layout
|
||||
const saved = this.savedLayouts.get(id);
|
||||
|
||||
let x, y, rotation, z;
|
||||
|
||||
if (saved) {
|
||||
x = saved.x;
|
||||
y = saved.y;
|
||||
rotation = saved.rotation;
|
||||
z = saved.z;
|
||||
// Ensure zCounter is higher than any loaded z
|
||||
if (z >= this.zCounter) {
|
||||
this.zCounter = z + 1;
|
||||
}
|
||||
|
||||
card = new LayoutCard(id, this, {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
z,
|
||||
pageCount: config.pageCount
|
||||
}, ref);
|
||||
} else {
|
||||
// Create card with temporary position
|
||||
card = new LayoutCard(id, this, {
|
||||
width,
|
||||
height,
|
||||
z: this.zCounter++,
|
||||
pageCount: config.pageCount
|
||||
}, ref);
|
||||
|
||||
// Calculate initial position using the card instance
|
||||
const { x: initX, y: initY, rotation: initRotation } = getInitialPosition(
|
||||
this.containerWidth,
|
||||
this.containerHeight,
|
||||
card,
|
||||
Array.from(this.items.values())
|
||||
);
|
||||
|
||||
// Update card with calculated position
|
||||
card.update({ x: initX, y: initY, rotation: initRotation }, { markDirty: true });
|
||||
}
|
||||
|
||||
this.items.set(id, card);
|
||||
} else {
|
||||
card.update({ width, height, pageCount: config.pageCount }, { markDirty: false });
|
||||
}
|
||||
|
||||
// Always update ref and ensure transform is applied
|
||||
if (card.ref !== ref) {
|
||||
card.setRef(ref);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
unregister(id: string) {
|
||||
this.items.delete(id);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items.clear();
|
||||
this.zCounter = 100;
|
||||
this.savedLayouts.clear();
|
||||
}
|
||||
|
||||
setContainerSize(width: number, height: number) {
|
||||
this.containerWidth = width;
|
||||
this.containerHeight = height;
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
relayout() {
|
||||
for (const card of this.items.values()) {
|
||||
const { x, y } = card.getConstrainedPosition(card.intendedX, card.intendedY);
|
||||
if (x !== card.x || y !== card.y) {
|
||||
card.update({ x, y }, { markDirty: false, isConstraintUpdate: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] {
|
||||
const result: LayoutCard[] = [];
|
||||
for (const card of this.items.values()) {
|
||||
// Calculate center of candidate card
|
||||
const cx = card.x + card.width / 2;
|
||||
const cy = card.y + card.height / 2;
|
||||
|
||||
const dx = cx - x;
|
||||
const dy = cy - y;
|
||||
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const limit = radius;
|
||||
|
||||
if (distSq < limit * limit) {
|
||||
result.push(card);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getStackBelow(topCard: LayoutCard): string[] {
|
||||
const centerX = topCard.x + topCard.width / 2;
|
||||
const centerY = topCard.y + topCard.height / 2;
|
||||
const candidates = this.getCardsInCircle(centerX, centerY, topCard.innerRadius);
|
||||
|
||||
return candidates
|
||||
.filter(other => {
|
||||
if (other.id === topCard.id) return false;
|
||||
if (other.z >= topCard.z) return false;
|
||||
return true;
|
||||
})
|
||||
.map(c => c.id);
|
||||
}
|
||||
|
||||
bringToFront(ids: string[]) {
|
||||
const cards = ids
|
||||
.map(id => this.items.get(id))
|
||||
.filter((c): c is LayoutCard => !!c);
|
||||
|
||||
// Sort by current Z-index to preserve relative order
|
||||
cards.sort((a, b) => a.z - b.z);
|
||||
|
||||
// Assign new Z-indices
|
||||
for (const card of cards) {
|
||||
card.update({ z: this.zCounter++ }, { markDirty: true });
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return Array.from(this.items.values()).map(card => card.toSnapshot());
|
||||
}
|
||||
|
||||
async loadLayout(tenantId: string, viewId: string) {
|
||||
this.tenantId = tenantId;
|
||||
this.viewId = viewId;
|
||||
|
||||
const records = await fetchLayoutRecords({ tenantId, viewId });
|
||||
|
||||
this.savedLayouts.clear();
|
||||
let maxZ = this.zCounter;
|
||||
|
||||
for (const record of records) {
|
||||
if (record.documentId && record.centerX !== undefined && record.centerY !== undefined) {
|
||||
this.savedLayouts.set(record.documentId, {
|
||||
x: record.centerX,
|
||||
y: record.centerY,
|
||||
rotation: record.rotation || 0,
|
||||
z: record.zIndex || 0
|
||||
});
|
||||
if (record.zIndex && record.zIndex >= maxZ) {
|
||||
maxZ = record.zIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.zCounter = maxZ;
|
||||
|
||||
// Apply to existing items if any (though usually this runs before items are created)
|
||||
for (const [id, card] of this.items) {
|
||||
const saved = this.savedLayouts.get(id);
|
||||
if (saved) {
|
||||
card.update(saved, { markDirty: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure everything is within bounds
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
hasSavedLayout(id: string): boolean {
|
||||
return this.savedLayouts.has(id);
|
||||
}
|
||||
|
||||
async saveLayout() {
|
||||
if (!this.tenantId || !this.viewId) return;
|
||||
|
||||
const dirtyCards = Array.from(this.items.values()).filter(card => card.isDirty);
|
||||
if (dirtyCards.length === 0) return;
|
||||
|
||||
const entries = dirtyCards.map(card => ({
|
||||
documentId: card.id,
|
||||
centerX: card.intendedX,
|
||||
centerY: card.intendedY,
|
||||
rotation: card.rotation,
|
||||
zIndex: card.z,
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
await upsertLayoutRecords({
|
||||
tenantId: this.tenantId,
|
||||
viewId: this.viewId,
|
||||
entries
|
||||
});
|
||||
|
||||
// Reset dirty flag for saved cards
|
||||
for (const card of dirtyCards) {
|
||||
card.isDirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { DocumentId } from '../../types/identifiers';
|
||||
import { DB_NAME, DB_VERSION, LAYOUT_STORE } from '../../constants/desktop';
|
||||
type TenantId = import('../../types/identifiers').TenantId;
|
||||
|
||||
const currentDbPromise: { value: Promise<IDBDatabase | null> | null } = { value: null };
|
||||
|
||||
const openDatabase = (): Promise<IDBDatabase> => {
|
||||
if (currentDbPromise.value) {
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
}
|
||||
|
||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(LAYOUT_STORE)) {
|
||||
const store = db.createObjectStore(LAYOUT_STORE, {
|
||||
keyPath: ['tenantId', 'viewId', 'documentId'],
|
||||
});
|
||||
store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false });
|
||||
store.createIndex('tenantIdx', 'tenantId', { unique: false });
|
||||
store.createIndex('updatedIdx', 'updatedAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
});
|
||||
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
};
|
||||
|
||||
const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const { result } = request;
|
||||
resolve(result ?? defaultValue);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB request failed'));
|
||||
};
|
||||
});
|
||||
|
||||
const transactionComplete = (transaction: IDBTransaction) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||
};
|
||||
});
|
||||
|
||||
type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
|
||||
|
||||
const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise<T> | T): Promise<T> => {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
const done = transactionComplete(transaction);
|
||||
try {
|
||||
const result = await handler(store, transaction);
|
||||
await done;
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch (abortError) {
|
||||
console.warn('[desk] Failed to abort transaction', abortError);
|
||||
}
|
||||
try {
|
||||
await done;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
interface LayoutRecord {
|
||||
tenantId: TenantId;
|
||||
viewId: string;
|
||||
documentId: DocumentId;
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
zIndex?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const fetchLayoutRecords = async ({
|
||||
tenantId,
|
||||
viewId,
|
||||
}: {
|
||||
tenantId?: TenantId;
|
||||
viewId?: string;
|
||||
}): Promise<LayoutRecord[]> => {
|
||||
if (!tenantId || !viewId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await withStore('readonly', (store) => {
|
||||
const index = store.index('tenantViewIdx');
|
||||
return requestToPromise(index.getAll([tenantId, viewId]), []);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to read layout records', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertLayoutRecords = async ({
|
||||
tenantId,
|
||||
viewId,
|
||||
entries,
|
||||
}: {
|
||||
tenantId?: TenantId;
|
||||
viewId?: string;
|
||||
entries?: Array<{
|
||||
documentId?: DocumentId;
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
zIndex?: number;
|
||||
updatedAt?: number;
|
||||
}>;
|
||||
}) => {
|
||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withStore('readwrite', (store) => {
|
||||
const timestamp = Date.now();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.documentId) {
|
||||
return;
|
||||
}
|
||||
store.put({
|
||||
tenantId,
|
||||
viewId,
|
||||
documentId: entry.documentId,
|
||||
centerX: Number(entry.centerX) || 0,
|
||||
centerY: Number(entry.centerY) || 0,
|
||||
rotation: Number(entry.rotation) || 0,
|
||||
zIndex: Number(entry.zIndex) || 0,
|
||||
updatedAt: entry.updatedAt || timestamp,
|
||||
} satisfies LayoutRecord);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to upsert layout records', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { LayoutCard } from '../logic/LayoutSystem';
|
||||
|
||||
export const CONTAINER_PADDING = 18;
|
||||
|
||||
export const constrainDimensions = (width: number, height: number, maxDimension: number) => {
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const aspect = width / height;
|
||||
if (width > height) {
|
||||
return {
|
||||
width: maxDimension,
|
||||
height: maxDimension / aspect
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
width: maxDimension * aspect,
|
||||
height: maxDimension
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialPosition = (
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
card: LayoutCard,
|
||||
_existingCards: LayoutCard[] = []
|
||||
): { x: number, y: number, rotation: number } => {
|
||||
// Mitchell's Best-Candidate Algorithm (Monte Carlo)
|
||||
const K = 20; // Number of candidates to test
|
||||
let bestCandidate = { x: 0, y: 0, rotation: 0 };
|
||||
let bestScore = -Infinity;
|
||||
|
||||
// Padding to keep cards inside
|
||||
const padding = CONTAINER_PADDING;
|
||||
|
||||
// Calculate safe bounds for top-left corner
|
||||
const minX = padding;
|
||||
const maxX = Math.max(padding, containerWidth - card.width - padding);
|
||||
const minY = padding;
|
||||
const maxY = Math.max(padding, containerHeight - card.height - padding);
|
||||
|
||||
const newCardRadius = card.outerRadius;
|
||||
const halfWidth = card.width / 2;
|
||||
const halfHeight = card.height / 2;
|
||||
|
||||
for (let i = 0; i < K; i++) {
|
||||
const x = minX + Math.random() * (maxX - minX);
|
||||
const y = minY + Math.random() * (maxY - minY);
|
||||
|
||||
const cx = x + halfWidth;
|
||||
const cy = y + halfHeight;
|
||||
|
||||
// Distance to nearest edge
|
||||
const distEdge = Math.min(
|
||||
x, // Left
|
||||
containerWidth - (x + card.width), // Right
|
||||
y, // Top
|
||||
containerHeight - (y + card.height) // Bottom
|
||||
);
|
||||
|
||||
// Distance to nearest neighbor
|
||||
let minNeighborDist = Infinity;
|
||||
for (const other of _existingCards) {
|
||||
const dx = cx - other.centerX;
|
||||
const dy = cy - other.centerY;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
const radiiSum = newCardRadius + other.outerRadius;
|
||||
const distToEdge = distSq - radiiSum * radiiSum;
|
||||
|
||||
if (distToEdge < minNeighborDist) {
|
||||
minNeighborDist = distToEdge;
|
||||
}
|
||||
}
|
||||
|
||||
const score = Math.min(distEdge, minNeighborDist);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCandidate = { x, y, rotation: Math.random() * 10 - 5 };
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { NBSP } from '../constants/ui';
|
||||
|
||||
interface CorrespondentLinkEntry {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentLinksProps {
|
||||
correspondents?: CorrespondentLinkEntry[];
|
||||
activeCorrespondentIdSet?: Set<string>;
|
||||
onCorrespondentClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||
correspondents,
|
||||
activeCorrespondentIdSet,
|
||||
onCorrespondentClick,
|
||||
}) => {
|
||||
if (!Array.isArray(correspondents) || correspondents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSet = activeCorrespondentIdSet || new Set<string>();
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
|
||||
if (!onCorrespondentClick || correspondent.id == null) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onCorrespondentClick(correspondent.id);
|
||||
};
|
||||
|
||||
return correspondents.map((correspondent, index) => {
|
||||
const isActive = correspondent.id != null && activeSet.has(correspondent.id);
|
||||
const hasHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
||||
const classNames = ['doc-correspondent-link'];
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const fallbackLabel = correspondent.name ?? '—';
|
||||
const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel;
|
||||
|
||||
return (
|
||||
<React.Fragment
|
||||
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames.join(' ')}
|
||||
aria-disabled={hasHandler ? undefined : true}
|
||||
onClick={(event) => handleClick(event, correspondent)}
|
||||
onKeyDown={(event) => {
|
||||
if (!hasHandler) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleClick(event, correspondent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{isLast ? null : <span className="doc-correspondent-link__separator">, </span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default CorrespondentLinks;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||
import type { Document } from '../types/documents';
|
||||
import {
|
||||
getAssetFromVersion,
|
||||
resolveDocumentAssetUrl,
|
||||
resolveAssetUrl,
|
||||
} from '../lib/assets/AssetManager';
|
||||
import { DEFAULT_THUMBNAIL_SIZE } from '../constants/documents';
|
||||
import type {
|
||||
Asset as AssetManagerAsset,
|
||||
EnsureAssetUrl as AssetManagerEnsureAssetUrl,
|
||||
GetAsset as AssetManagerGetAsset,
|
||||
} from '../lib/assets/AssetManager';
|
||||
|
||||
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||
const useLazyVisibility = (
|
||||
rootRef: MutableRefObject<Element | null> | null,
|
||||
resetKey?: string | null,
|
||||
) => {
|
||||
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
}, [resetKey]);
|
||||
|
||||
const rootNode = rootRef?.current || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const element = targetRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!window.IntersectionObserver) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: rootNode,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [isVisible, rootNode, resetKey]);
|
||||
|
||||
return { ref: targetRef, isVisible };
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getPageCount = (doc?: Document | null): number | null => {
|
||||
return doc?.current_version?.metadata?.page_count;
|
||||
};
|
||||
|
||||
type Asset = AssetManagerAsset;
|
||||
type EnsureAssetUrl = AssetManagerEnsureAssetUrl;
|
||||
type GetAsset = AssetManagerGetAsset;
|
||||
|
||||
interface DocumentThumbnailImageProps {
|
||||
document?: Document | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetAsset;
|
||||
alt?: string;
|
||||
maxSize?: number;
|
||||
scrollRootRef?: MutableRefObject<Element | null> | null;
|
||||
}
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getAsset,
|
||||
alt = '',
|
||||
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
||||
scrollRootRef = null,
|
||||
}: DocumentThumbnailImageProps): JSX.Element => {
|
||||
const documentId = document?.id;
|
||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
|
||||
const thumbnailAsset = useMemo<Asset | null>(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
[document?.current_version],
|
||||
);
|
||||
const thumbnailMetadata = (thumbnailAsset?.metadata as { width?: number; height?: number } | null) || null;
|
||||
const assetWidth = thumbnailMetadata?.width;
|
||||
const assetHeight = thumbnailMetadata?.height;
|
||||
|
||||
const dimensions = useMemo(() => {
|
||||
const hasDimensions = typeof assetWidth === 'number' && assetWidth > 0 && typeof assetHeight === 'number' && assetHeight > 0;
|
||||
if (!hasDimensions) {
|
||||
return { width: resolvedMaxSize, height: resolvedMaxSize };
|
||||
}
|
||||
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
|
||||
return {
|
||||
width: Math.max(1, Math.round(assetWidth * scale)),
|
||||
height: Math.max(1, Math.round(assetHeight * scale)),
|
||||
};
|
||||
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||
|
||||
const innerStyle = useMemo<CSSProperties>(
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
const options: {
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetAsset;
|
||||
} = {};
|
||||
if (ensureAssetUrl) {
|
||||
options.ensureAssetUrl = ensureAssetUrl;
|
||||
}
|
||||
if (getAsset) {
|
||||
options.getAsset = getAsset;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', options) || resolveAssetUrl(thumbnailAsset);
|
||||
}, [document, ensureAssetUrl, getAsset, isVisible, thumbnailAsset]);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
const showMultiPageBadge = pageCount !== null && pageCount > 1;
|
||||
const innerClasses = ['document-thumbnail-inner'];
|
||||
if (showMultiPageBadge) {
|
||||
innerClasses.push('document-thumbnail-inner--multipage');
|
||||
}
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (dimensions.width > 0 && dimensions.height > 0) {
|
||||
return dimensions.width / dimensions.height;
|
||||
}
|
||||
return null;
|
||||
}, [dimensions.height, dimensions.width]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = visibilityRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (aspectRatio) {
|
||||
node.dataset.thumbnailAspect = String(aspectRatio);
|
||||
} else {
|
||||
delete node.dataset.thumbnailAspect;
|
||||
}
|
||||
}, [aspectRatio, visibilityRef]);
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className="document-thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumb-placeholder">DOC</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentThumbnailImage;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import type { DocumentId, Identifier, TagId } from '../types/identifiers';
|
||||
import type { Tag, Correspondent } from '../types/documents';
|
||||
import type TagManager from '../lib/assets/TagManager';
|
||||
import type CorrespondentManager from '../lib/assets/CorrespondentManager';
|
||||
|
||||
type ManagedDocument = { id?: DocumentId | null; tags?: Identifier[] | null; correspondents?: Identifier[] | null } & Record<string, unknown>;
|
||||
|
||||
type FetchDocument = (id: DocumentId) => Promise<unknown>;
|
||||
|
||||
class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
|
||||
private byId: Map<DocumentId, T>;
|
||||
private fetcher?: FetchDocument;
|
||||
private inflight: Map<DocumentId, Promise<T | null>>;
|
||||
private listeners: Set<() => void>;
|
||||
private emitScheduled: boolean;
|
||||
private tagManager?: TagManager;
|
||||
private correspondentManager?: CorrespondentManager;
|
||||
|
||||
constructor(
|
||||
fetchDocument?: FetchDocument,
|
||||
) {
|
||||
this.byId = new Map();
|
||||
this.fetcher = fetchDocument;
|
||||
this.inflight = new Map();
|
||||
this.listeners = new Set();
|
||||
this.emitScheduled = false;
|
||||
}
|
||||
|
||||
setTagManager(tagManager: TagManager) {
|
||||
this.tagManager = tagManager;
|
||||
}
|
||||
|
||||
setCorrespondentManager(correspondentManager: CorrespondentManager) {
|
||||
this.correspondentManager = correspondentManager;
|
||||
}
|
||||
|
||||
private emit() {
|
||||
if (this.emitScheduled) {
|
||||
return;
|
||||
}
|
||||
this.emitScheduled = true;
|
||||
setTimeout(() => {
|
||||
this.emitScheduled = false;
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}, 0);
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
setFetcher(fetchDocument?: FetchDocument) {
|
||||
this.fetcher = fetchDocument;
|
||||
}
|
||||
|
||||
ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } {
|
||||
const docs = rawDocs.map((doc) => doc as T).filter(Boolean);
|
||||
let changed = false;
|
||||
let nextById = this.byId;
|
||||
const canonical: T[] = [];
|
||||
|
||||
docs.forEach((doc) => {
|
||||
const id = doc?.id;
|
||||
if (id == null) {
|
||||
canonical.push(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tagManager && Array.isArray((doc as any).tags)) {
|
||||
const rawTags = (doc as any).tags as any[];
|
||||
const validTags: Tag[] = [];
|
||||
const tagIds: TagId[] = [];
|
||||
|
||||
rawTags.forEach(tag => {
|
||||
if (tag.id) {
|
||||
tagIds.push(tag.id);
|
||||
validTags.push(tag as Tag);
|
||||
}
|
||||
});
|
||||
|
||||
if (validTags.length > 0) {
|
||||
this.tagManager.ingest(validTags);
|
||||
}
|
||||
|
||||
(doc as any).tags = tagIds;
|
||||
}
|
||||
|
||||
if (this.correspondentManager && Array.isArray((doc as any).correspondents)) {
|
||||
const rawCorrespondents = (doc as any).correspondents as any[];
|
||||
const validCorrespondents: Correspondent[] = [];
|
||||
const correspondentIds: Identifier[] = [];
|
||||
|
||||
rawCorrespondents.forEach(corr => {
|
||||
if (corr.id) {
|
||||
correspondentIds.push(corr.id);
|
||||
validCorrespondents.push(corr as Correspondent);
|
||||
}
|
||||
});
|
||||
|
||||
if (validCorrespondents.length > 0) {
|
||||
this.correspondentManager.ingest(validCorrespondents);
|
||||
}
|
||||
(doc as any).correspondents = correspondentIds;
|
||||
}
|
||||
|
||||
const existing = nextById.get(id as DocumentId);
|
||||
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
|
||||
const useExisting = existing && shallowEqual(existing, merged);
|
||||
const nextDoc = useExisting ? (existing as T) : merged;
|
||||
|
||||
if (!useExisting) {
|
||||
if (!changed) {
|
||||
nextById = new Map(this.byId);
|
||||
}
|
||||
nextById.set(id as DocumentId, nextDoc);
|
||||
changed = true;
|
||||
}
|
||||
canonical.push(nextDoc);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.byId = nextById;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
return { canonical, changed };
|
||||
}
|
||||
|
||||
async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise<T | null> {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.byId.get(id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fetcher = fetcherOverride || this.fetcher;
|
||||
if (!fetcher) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inflight = this.inflight.get(id);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const fetched = await fetcher(id);
|
||||
const { canonical } = this.ingest([fetched as unknown]);
|
||||
return canonical[0] ?? null;
|
||||
} finally {
|
||||
this.inflight.delete(id);
|
||||
}
|
||||
})();
|
||||
|
||||
this.inflight.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
update(id: DocumentId, updater: (doc: T) => Partial<T> | T | undefined): boolean {
|
||||
const doc = this.byId.get(id);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
const changes = updater(doc);
|
||||
if (!changes) {
|
||||
return false;
|
||||
}
|
||||
const { changed } = this.ingest([{ ...doc, ...changes }]);
|
||||
return changed;
|
||||
}
|
||||
|
||||
map(mapper: (doc: T) => T | undefined): boolean {
|
||||
if (!this.byId.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const next = new Map<DocumentId, T>();
|
||||
this.byId.forEach((doc, key) => {
|
||||
const updated = mapper(doc);
|
||||
const nextDoc = updated === undefined ? doc : updated;
|
||||
if (nextDoc !== doc) {
|
||||
changed = true;
|
||||
}
|
||||
next.set(key, nextDoc ?? doc);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.byId = next;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
remove(ids: Array<DocumentId>): boolean {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
let next = this.byId;
|
||||
ids.forEach((id) => {
|
||||
if (next.has(id)) {
|
||||
if (!changed) {
|
||||
next = new Map(this.byId);
|
||||
}
|
||||
next.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
this.byId = next;
|
||||
this.emit();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
getById(id: DocumentId): T | null {
|
||||
return this.byId.get(id) ?? null;
|
||||
}
|
||||
|
||||
getMany(ids: Array<DocumentId> = []): T[] {
|
||||
return ids
|
||||
.map((id) => this.byId.get(id) || null)
|
||||
.filter((doc): doc is T => Boolean(doc));
|
||||
}
|
||||
|
||||
getSnapshot(): Map<DocumentId, T> {
|
||||
return this.byId;
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentsManager;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useDocumentViewLogic, DocumentViewLogic } from './logic/useDocumentViewLogic';
|
||||
import { useDocumentsNavigation } from './logic/useDocumentsNavigation';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
import { usePanelManager } from '../app/PanelManagerContext';
|
||||
import DocumentsListRow from './components/DocumentsListRow';
|
||||
import DocumentsGridCard from './components/DocumentsGridCard';
|
||||
import DocumentsListContainer from './components/DocumentsListContainer';
|
||||
import DocumentsGridContainer from './components/DocumentsGridContainer';
|
||||
import type { DocumentsViewProps } from './panel/DocumentsPanel';
|
||||
import { useDocumentsViewStateContext } from './context/DocumentsViewStateContext';
|
||||
import { useDocumentsCommandContext } from './context/DocumentsCommandContext';
|
||||
|
||||
interface AbstractDocumentsViewProps<CProps extends { clearSelection: () => void; children: React.ReactNode }> extends DocumentsViewProps {
|
||||
ContainerComponent: React.ComponentType<CProps>;
|
||||
ItemComponent: React.ComponentType<{ entry: any; viewLogic: DocumentViewLogic } & DocumentsViewProps>;
|
||||
containerProps?: Omit<CProps, 'children' | 'clearSelection'>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const AbstractDocumentsView = <CProps extends { clearSelection: () => void; children: React.ReactNode }>({
|
||||
ContainerComponent,
|
||||
ItemComponent,
|
||||
containerProps,
|
||||
...props
|
||||
}: AbstractDocumentsViewProps<CProps>) => {
|
||||
const { entries, viewMode } = props;
|
||||
const {
|
||||
viewId,
|
||||
scrollRef
|
||||
} = useDocumentsViewStateContext();
|
||||
const {
|
||||
document: { onRename: onDocumentRename },
|
||||
folder: { onRename: onFolderRename, onSelect: onFolderSelect }
|
||||
} = useDocumentsCommandContext();
|
||||
|
||||
const viewLogic = useDocumentViewLogic({
|
||||
onDocumentRename,
|
||||
onFolderRename,
|
||||
});
|
||||
const { handleKeyDown, handleFocus } = useDocumentsNavigation({
|
||||
entries,
|
||||
onFolderSelect,
|
||||
viewMode: viewMode || props.viewMode,
|
||||
scrollRef: scrollRef,
|
||||
});
|
||||
const { clearSelection } = viewLogic;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef?.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [scrollRef, viewId]);
|
||||
|
||||
const { focusedEntryKey } = useWorkspaceSelectionContext();
|
||||
|
||||
const ensureFocusedEntryVisible = useCallback(() => {
|
||||
if (!focusedEntryKey) return;
|
||||
const container = scrollRef?.current;
|
||||
if (!container) return;
|
||||
let selector = null;
|
||||
if (focusedEntryKey.startsWith('document:')) {
|
||||
selector = `#document-${focusedEntryKey.slice('document:'.length)}`;
|
||||
} else if (focusedEntryKey.startsWith('folder:')) {
|
||||
selector = `#folder-${focusedEntryKey.slice('folder:'.length)}`;
|
||||
}
|
||||
if (!selector) {
|
||||
return;
|
||||
}
|
||||
const entry = container.querySelector(selector) as HTMLElement;
|
||||
if (!entry || !container.contains(entry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.scrollIntoView({ block: 'nearest' });
|
||||
}, [focusedEntryKey, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureFocusedEntryVisible();
|
||||
}, [ensureFocusedEntryVisible]);
|
||||
|
||||
const { detailPanelOpen } = usePanelManager();
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef?.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
ensureFocusedEntryVisible();
|
||||
};
|
||||
|
||||
container.addEventListener('transitionend', handleTransitionEnd);
|
||||
|
||||
// Immediate check in case there is no transition or it finished already
|
||||
ensureFocusedEntryVisible();
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('transitionend', handleTransitionEnd);
|
||||
};
|
||||
}, [scrollRef, ensureFocusedEntryVisible, detailPanelOpen]);
|
||||
|
||||
return (
|
||||
<ContainerComponent
|
||||
clearSelection={clearSelection}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
tabIndex={0}
|
||||
{...(containerProps as any)}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<ItemComponent
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
viewLogic={viewLogic}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</ContainerComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentsList: React.FC<DocumentsViewProps & { iconSize?: number }> = (props) => {
|
||||
return (
|
||||
<AbstractDocumentsView
|
||||
ContainerComponent={DocumentsListContainer}
|
||||
ItemComponent={DocumentsListRow}
|
||||
viewMode="list"
|
||||
containerProps={{ iconSize: props.iconSize }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentsGrid: React.FC<DocumentsViewProps & { iconSize?: number }> = (props) => {
|
||||
return (
|
||||
<AbstractDocumentsView
|
||||
ContainerComponent={DocumentsGridContainer}
|
||||
ItemComponent={DocumentsGridCard}
|
||||
containerProps={{ iconSize: props.iconSize }}
|
||||
viewMode="grid"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import type { FolderNodeId } from '../types/identifiers';
|
||||
import type { Folder } from '../types/documents';
|
||||
import { createRootNode } from '../app/workspaceUtils';
|
||||
|
||||
import type { FolderTreeNode, FolderInfo } from '../lib/api/apiTypes';
|
||||
import {
|
||||
createFolder as apiCreateFolder,
|
||||
deleteFolder as apiDeleteFolder,
|
||||
moveFolder as apiMoveFolder,
|
||||
renameFolder as apiRenameFolder,
|
||||
getFolderTree
|
||||
} from '../lib/api/apiClient';
|
||||
import { flattenFolderTree } from '../app/workspaceUtils';
|
||||
|
||||
type FetchFolder = (id: FolderNodeId) => Promise<unknown>;
|
||||
|
||||
class FoldersManager {
|
||||
private byId: Map<FolderNodeId, Folder>;
|
||||
private fetcher?: FetchFolder;
|
||||
private inflight: Map<FolderNodeId, Promise<Folder | null>>;
|
||||
private treePromise: Promise<FolderTreeNode[]> | null = null;
|
||||
private treeSnapshot: FolderTreeNode[] = [];
|
||||
private listeners: Set<() => void>;
|
||||
private emitScheduled: boolean;
|
||||
|
||||
|
||||
constructor(
|
||||
fetchFolder?: FetchFolder,
|
||||
) {
|
||||
this.byId = new Map();
|
||||
this.fetcher = fetchFolder;
|
||||
this.inflight = new Map();
|
||||
this.listeners = new Set();
|
||||
this.emitScheduled = false;
|
||||
}
|
||||
|
||||
private emit() {
|
||||
if (this.emitScheduled) {
|
||||
return;
|
||||
}
|
||||
this.emitScheduled = true;
|
||||
setTimeout(() => {
|
||||
this.emitScheduled = false;
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}, 0);
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
setFetcher(fetchFolder?: FetchFolder) {
|
||||
this.fetcher = fetchFolder;
|
||||
}
|
||||
|
||||
ingest(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } {
|
||||
const result = this.ingestInternal(rawFolders);
|
||||
if (result.changed) {
|
||||
this.emit();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ingestInternal(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } {
|
||||
const folders = rawFolders.map((f) => f as Folder).filter(Boolean);
|
||||
let changed = false;
|
||||
let nextById = this.byId;
|
||||
const canonical: Folder[] = [];
|
||||
|
||||
folders.forEach((folder) => {
|
||||
const id = folder?.id;
|
||||
if (id == null) {
|
||||
canonical.push(folder);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = nextById.get(id as FolderNodeId);
|
||||
const merged = existing ? ({ ...existing, ...folder } as Folder) : ({ ...(folder as Folder) } as Folder);
|
||||
const useExisting = existing && shallowEqual(existing, merged);
|
||||
const nextFolder = useExisting ? (existing as Folder) : merged;
|
||||
|
||||
if (!useExisting) {
|
||||
if (!changed) {
|
||||
nextById = new Map(this.byId);
|
||||
}
|
||||
nextById.set(id as FolderNodeId, nextFolder);
|
||||
changed = true;
|
||||
}
|
||||
canonical.push(nextFolder);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.byId = nextById;
|
||||
}
|
||||
|
||||
return { canonical, changed };
|
||||
}
|
||||
|
||||
async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise<Folder | null> {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.byId.get(id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fetcher = fetcherOverride || this.fetcher;
|
||||
if (!fetcher) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inflight = this.inflight.get(id);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const fetched = await fetcher(id);
|
||||
const { canonical } = this.ingest([fetched as unknown]);
|
||||
return canonical[0] ?? null;
|
||||
} finally {
|
||||
this.inflight.delete(id);
|
||||
}
|
||||
})();
|
||||
|
||||
this.inflight.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
map(mapper: (folder: Folder) => Folder | undefined): boolean {
|
||||
if (!this.byId.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const next = new Map<FolderNodeId, Folder>();
|
||||
this.byId.forEach((folder, key) => {
|
||||
const updated = mapper(folder);
|
||||
const nextFolder = updated === undefined ? folder : updated;
|
||||
if (nextFolder !== folder) {
|
||||
changed = true;
|
||||
}
|
||||
next.set(key, nextFolder ?? folder);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.byId = next;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
remove(ids: Array<FolderNodeId>): boolean {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
let next = this.byId;
|
||||
ids.forEach((id) => {
|
||||
if (next.has(id)) {
|
||||
if (!changed) {
|
||||
next = new Map(this.byId);
|
||||
}
|
||||
next.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
this.byId = next;
|
||||
this.emit();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async create(name: string, parentId: FolderNodeId | null): Promise<Folder> {
|
||||
const payload = {
|
||||
name,
|
||||
parent_id: parentId === 'root' ? null : parentId
|
||||
};
|
||||
const response = await apiCreateFolder(payload);
|
||||
const folderData = response.folder as unknown as Folder;
|
||||
|
||||
if (!folderData?.id) {
|
||||
throw new Error('Folder creation failed: No ID returned');
|
||||
}
|
||||
|
||||
this.addNode(folderData);
|
||||
return folderData;
|
||||
}
|
||||
|
||||
async delete(id: FolderNodeId): Promise<void> {
|
||||
await apiDeleteFolder(id);
|
||||
this.removeNode(id);
|
||||
}
|
||||
|
||||
async rename(id: FolderNodeId, name: string): Promise<void> {
|
||||
await apiRenameFolder(id, name);
|
||||
|
||||
// Update local state
|
||||
const existing = this.byId.get(id);
|
||||
if (existing) {
|
||||
this.ingest([{ ...existing, name }]);
|
||||
}
|
||||
|
||||
// Update tree node
|
||||
const node = this.findNode(this.treeSnapshot, id);
|
||||
if (node) {
|
||||
node.name = name;
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
async move(id: FolderNodeId, parentId: FolderNodeId | null): Promise<void> {
|
||||
const targetParentId = parentId === 'root' ? null : parentId;
|
||||
await apiMoveFolder(id, targetParentId);
|
||||
|
||||
// Update local state 'parent_id'
|
||||
const existing = this.byId.get(id);
|
||||
if (existing) {
|
||||
this.ingest([{ ...existing, parent_id: targetParentId }]);
|
||||
}
|
||||
|
||||
this.moveNode(id, parentId);
|
||||
}
|
||||
|
||||
private moveNode(id: FolderNodeId, parentId: FolderNodeId | null) {
|
||||
const node = this.findNode(this.treeSnapshot, id);
|
||||
if (!node) return;
|
||||
|
||||
this.removeNodeFromParent(this.treeSnapshot, id);
|
||||
|
||||
node.parent_id = (parentId as string) || null;
|
||||
|
||||
const attachToRoot = !parentId || parentId === 'root';
|
||||
if (attachToRoot) {
|
||||
const root = this.treeSnapshot[0];
|
||||
if (root) {
|
||||
root.children = [...(root.children || []), node];
|
||||
root.hasChildren = true;
|
||||
}
|
||||
} else {
|
||||
const newParent = this.findNode(this.treeSnapshot, parentId);
|
||||
if (newParent) {
|
||||
newParent.children = [...(newParent.children || []), node];
|
||||
newParent.hasChildren = true;
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private removeNodeFromParent(nodes: FolderTreeNode[], id: FolderNodeId): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
const idx = node.children.findIndex(c => c.id === id);
|
||||
if (idx !== -1) {
|
||||
node.children.splice(idx, 1);
|
||||
if (node.children.length === 0) {
|
||||
node.hasChildren = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (this.removeNodeFromParent(node.children, id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getById(id: FolderNodeId): Folder | null {
|
||||
return this.byId.get(id) ?? null;
|
||||
}
|
||||
|
||||
getMany(ids: Array<FolderNodeId> = []): Folder[] {
|
||||
return ids
|
||||
.map((id) => this.byId.get(id) || null)
|
||||
.filter((folder): folder is Folder => Boolean(folder));
|
||||
}
|
||||
|
||||
getSnapshot(): Map<FolderNodeId, Folder> {
|
||||
return this.byId;
|
||||
}
|
||||
|
||||
getTreeSnapshot(): FolderTreeNode[] {
|
||||
return this.treeSnapshot;
|
||||
}
|
||||
|
||||
async ensureTree(): Promise<FolderTreeNode[]> {
|
||||
if (this.treeSnapshot.length > 0) {
|
||||
return this.treeSnapshot;
|
||||
}
|
||||
|
||||
if (this.treePromise) {
|
||||
return this.treePromise;
|
||||
}
|
||||
|
||||
this.treePromise = this.fetchTreeInternal();
|
||||
return this.treePromise;
|
||||
}
|
||||
|
||||
async refreshTree(): Promise<FolderTreeNode[]> {
|
||||
this.treePromise = this.fetchTreeInternal();
|
||||
return this.treePromise;
|
||||
}
|
||||
|
||||
private async fetchTreeInternal(): Promise<FolderTreeNode[]> {
|
||||
try {
|
||||
const raw = await getFolderTree();
|
||||
const flattened = flattenFolderTree(raw);
|
||||
this.ingest(flattened);
|
||||
const rootsPromises = raw as FolderTreeNode[];
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
|
||||
rootNode.children = rootsPromises;
|
||||
rootNode.hasChildren = rootsPromises.length > 0;
|
||||
rootNode.loaded = true;
|
||||
|
||||
this.treeSnapshot = [rootNode];
|
||||
this.emit();
|
||||
return [rootNode];
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch folder tree', error);
|
||||
// On error, do not clear existing snapshot if this was a refresh
|
||||
return this.treeSnapshot.length > 0 ? this.treeSnapshot : [];
|
||||
} finally {
|
||||
this.treePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
addNode(folder: Folder) {
|
||||
this.ingest([folder]);
|
||||
|
||||
const newNode: FolderTreeNode = {
|
||||
...(folder as unknown as FolderInfo),
|
||||
children: [],
|
||||
hasChildren: false,
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
const parentId = folder.parent_id;
|
||||
if (!parentId || parentId === 'root') {
|
||||
const root = this.treeSnapshot[0];
|
||||
if (root) {
|
||||
root.children = [...(root.children || []), newNode];
|
||||
root.hasChildren = true;
|
||||
}
|
||||
} else {
|
||||
const parent = this.findNode(this.treeSnapshot, parentId);
|
||||
if (parent) {
|
||||
parent.children = [...(parent.children || []), newNode];
|
||||
parent.hasChildren = true;
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
removeNode(id: FolderNodeId) {
|
||||
this.remove([id]);
|
||||
|
||||
// The treeSnapshot usually contains one root node which holds the tree
|
||||
const changed = this.removeNodeFromParent(this.treeSnapshot, id);
|
||||
if (changed) {
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private findNode(nodes: FolderTreeNode[], id: FolderNodeId): FolderTreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = this.findNode(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default FoldersManager;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../../components/icons';
|
||||
import { resolveDocumentDownloadHref } from '../documentActions';
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
interface DocumentDownloadLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
document?: Document | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DocumentDownloadLink: React.FC<DocumentDownloadLinkProps> = ({
|
||||
document,
|
||||
children,
|
||||
className = 'icon-button',
|
||||
title = 'Download document',
|
||||
'aria-label': ariaLabel = 'Download document',
|
||||
...rest
|
||||
}) => {
|
||||
const downloadUrl = resolveDocumentDownloadHref(document);
|
||||
|
||||
if (!downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
title={title}
|
||||
aria-label={ariaLabel}
|
||||
{...rest}
|
||||
>
|
||||
{children || <DownloadIcon />}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentDownloadLink;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import type { DocumentViewLogic } from '../logic/useDocumentViewLogic';
|
||||
import { useDocumentItemLogic } from '../logic/useDocumentItemLogic';
|
||||
import EntryShell from './EntryShell';
|
||||
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||
|
||||
interface DocumentEntryProps {
|
||||
doc: any;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
viewLogic: DocumentViewLogic;
|
||||
component: React.ElementType;
|
||||
className?: string;
|
||||
role?: string;
|
||||
children: (logic: ReturnType<typeof useDocumentItemLogic>) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DocumentEntry: React.FC<DocumentEntryProps> = (props) => {
|
||||
const { doc, tagHandlers, component, className, role, children, viewLogic } = props;
|
||||
const logic = useDocumentItemLogic({ doc, tagHandlers, viewLogic });
|
||||
|
||||
return (
|
||||
<EntryShell
|
||||
component={component}
|
||||
id={`document-${doc.id}`}
|
||||
docId={doc.id}
|
||||
handlers={logic.handlers}
|
||||
isSelected={logic.isSelected}
|
||||
isDragging={logic.isDraggingDoc}
|
||||
canDrag={true}
|
||||
className={className}
|
||||
role={role}
|
||||
>
|
||||
{children(logic)}
|
||||
</EntryShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentEntry;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getTagColorStyle } from '../../utils/colors';
|
||||
import type { Document, Tag } from '../../types/documents';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||
|
||||
interface DocumentTagsProps {
|
||||
tags: Identifier[];
|
||||
tagLookupById?: Map<Identifier, Tag> | null;
|
||||
doc: Document;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
}
|
||||
|
||||
const DocumentTags: React.FC<DocumentTagsProps> = ({
|
||||
tags,
|
||||
tagLookupById,
|
||||
doc,
|
||||
tagHandlers,
|
||||
}) => {
|
||||
const resolvedTags = useMemo(() => {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.map(id => tagLookupById?.get(id))
|
||||
.filter((tag): tag is Tag => Boolean(tag))
|
||||
.sort((a, b) => {
|
||||
const labelA = a.label.toLowerCase();
|
||||
const labelB = b.label.toLowerCase();
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
}, [tags, tagLookupById]);
|
||||
|
||||
if (resolvedTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{resolvedTags.map((tag, index) => {
|
||||
const { color, label, id } = tag;
|
||||
const tagId = id;
|
||||
|
||||
const style = getTagColorStyle(color);
|
||||
const clickable = tagId != null && typeof tagHandlers?.onTagClick === 'function';
|
||||
const draggable = !!tagId;
|
||||
const key = tagId ?? `${doc.id}-tag-${index}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={`badge tag-chip${draggable ? ' tag-chip--draggable' : ''}${clickable ? ' tag-chip--clickable' : ''}`}
|
||||
style={style || undefined}
|
||||
title={label || ''}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
if (tagId == null) return;
|
||||
tagHandlers?.onTagClick?.(tagId);
|
||||
} : undefined}
|
||||
draggable={draggable}
|
||||
onDragStart={(event) => tagId && tagHandlers?.onTagDragStart(event, doc, tag)}
|
||||
onDragEnd={tagHandlers?.onTagDragEnd}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (tagId == null) return;
|
||||
tagHandlers?.onTagClick?.(tagId);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentTags;
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { FolderIcon } from '../../components/icons';
|
||||
import DocumentThumbnailImage from '../DocumentThumbnailImage';
|
||||
import { resolveCorrespondents } from '../correspondents';
|
||||
import type { DocumentsListEntry } from '../../types/documents';
|
||||
import { useDocumentsAssetContext } from '../context/DocumentsAssetContext';
|
||||
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
|
||||
import { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
|
||||
import type { DocumentViewLogic } from '../logic/useDocumentViewLogic';
|
||||
import EditableEntryTitle from './EditableEntryTitle';
|
||||
import EntryCorrespondents from './EntryCorrespondents';
|
||||
import DocumentTags from './DocumentTags';
|
||||
import FolderEntry from './FolderEntry';
|
||||
import DocumentEntry from './DocumentEntry';
|
||||
import { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||
|
||||
interface DocumentsGridCardProps {
|
||||
entry: DocumentsListEntry;
|
||||
viewLogic: DocumentViewLogic;
|
||||
iconSize?: number;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
}
|
||||
|
||||
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
|
||||
const { entry, iconSize, tagHandlers } = props;
|
||||
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
||||
const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||
const {
|
||||
correspondents: { onClick: onCorrespondentClick },
|
||||
} = useDocumentsCommandContext();
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<FolderEntry
|
||||
folder={folder}
|
||||
viewLogic={props.viewLogic}
|
||||
component="div"
|
||||
className="document-card folder-card"
|
||||
role="listitem"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<div className="folder-card__icon">
|
||||
<FolderIcon className="folder-card__icon-svg" size={iconSize} />
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
<div className="folder-card__label-row">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isFolderEditing}
|
||||
draftValue={logic.folderDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isFolderSaving}
|
||||
canSubmit={logic.canSubmitFolder}
|
||||
inputRef={logic.attachFolderInputRef}
|
||||
allowInlineEdit={logic.allowInlineFolderEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="folder-card__name"
|
||||
>
|
||||
{folder.name}
|
||||
</EditableEntryTitle>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FolderEntry>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) return null;
|
||||
|
||||
const correspondents = resolveCorrespondents(doc, correspondentLookupById);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
doc={doc}
|
||||
tagHandlers={tagHandlers}
|
||||
viewLogic={props.viewLogic}
|
||||
component="div"
|
||||
className="document-card document"
|
||||
role="listitem"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
maxSize={iconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div className="document-card__title" title={doc.title}>
|
||||
<EntryCorrespondents
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
<div className="document-card__title-row">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isEditingDoc}
|
||||
draftValue={logic.documentDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isDocumentSaving}
|
||||
canSubmit={logic.canSubmitDocument}
|
||||
inputRef={logic.attachDocumentInputRef}
|
||||
allowInlineEdit={logic.allowInlineDocumentEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="document-card__title-badge"
|
||||
>
|
||||
{doc.title}
|
||||
</EditableEntryTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-card__tags">
|
||||
<DocumentTags
|
||||
tags={doc.tags || []}
|
||||
tagLookupById={tagLookupById}
|
||||
doc={doc}
|
||||
tagHandlers={logic.handlers.tagHandlers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DocumentEntry>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default DocumentsGridCard;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DocumentsGridContainerProps {
|
||||
children: React.ReactNode;
|
||||
clearSelection: () => void;
|
||||
iconSize?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
|
||||
children,
|
||||
clearSelection,
|
||||
iconSize,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
{...props}
|
||||
style={
|
||||
iconSize
|
||||
? ({ '--documents-grid-icon-size': `${iconSize}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsGridContainer;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DocumentsListContainerProps {
|
||||
children: React.ReactNode;
|
||||
clearSelection: () => void;
|
||||
iconSize?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
|
||||
children,
|
||||
clearSelection,
|
||||
iconSize: _iconSize,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<table aria-multiselectable="true" {...props}>
|
||||
<thead
|
||||
onClick={() => {
|
||||
clearSelection();
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsListContainer;
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { FolderIcon } from '../../components/icons';
|
||||
import { formatDate } from '../../utils/date';
|
||||
import DocumentThumbnailImage from '../DocumentThumbnailImage';
|
||||
import { resolveCorrespondents } from '../correspondents';
|
||||
import type { DocumentsListEntry } from '../../types/documents';
|
||||
import { useDocumentsAssetContext } from '../context/DocumentsAssetContext';
|
||||
import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext';
|
||||
import { useDocumentsCommandContext } from '../context/DocumentsCommandContext';
|
||||
import type { DocumentViewLogic } from '../logic/useDocumentViewLogic';
|
||||
import EditableEntryTitle from './EditableEntryTitle';
|
||||
import EntryCorrespondents from './EntryCorrespondents';
|
||||
import DocumentTags from './DocumentTags';
|
||||
import FolderEntry from './FolderEntry';
|
||||
import DocumentEntry from './DocumentEntry';
|
||||
|
||||
import { TagInteractionHandlers } from '../interactions/useTagInteractions';
|
||||
|
||||
interface DocumentsListRowProps {
|
||||
entry: DocumentsListEntry;
|
||||
viewLogic: DocumentViewLogic;
|
||||
iconSize?: number;
|
||||
tagHandlers?: TagInteractionHandlers;
|
||||
}
|
||||
|
||||
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
|
||||
const { entry, iconSize, tagHandlers } = props;
|
||||
const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext();
|
||||
const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext();
|
||||
const {
|
||||
correspondents: { onClick: onCorrespondentClick },
|
||||
} = useDocumentsCommandContext();
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<FolderEntry
|
||||
folder={folder}
|
||||
viewLogic={props.viewLogic}
|
||||
component="tr"
|
||||
className="folder"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<td className="thumb-cell">
|
||||
<div className="thumb-icon">
|
||||
<FolderIcon className="thumb-icon__image" size={iconSize || 32} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
<span className="doc-name__primary">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isFolderEditing}
|
||||
draftValue={logic.folderDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isFolderSaving}
|
||||
canSubmit={logic.canSubmitFolder}
|
||||
inputRef={logic.attachFolderInputRef}
|
||||
allowInlineEdit={logic.allowInlineFolderEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="doc-name__primary-text"
|
||||
>
|
||||
{folder.name}
|
||||
</EditableEntryTitle>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</>
|
||||
)}
|
||||
</FolderEntry>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) return null;
|
||||
|
||||
const correspondents = resolveCorrespondents(doc, correspondentLookupById);
|
||||
const issuedLabel = formatDate(doc.issued_at);
|
||||
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
doc={doc}
|
||||
tagHandlers={tagHandlers}
|
||||
viewLogic={props.viewLogic}
|
||||
component="tr"
|
||||
className="document"
|
||||
>
|
||||
{(logic) => (
|
||||
<>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
scrollRootRef={scrollRef}
|
||||
maxSize={props.iconSize}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
<EntryCorrespondents
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
<span className="doc-name__primary">
|
||||
<EditableEntryTitle
|
||||
isEditing={logic.isEditingDoc}
|
||||
draftValue={logic.documentDraftValue}
|
||||
onChange={logic.handlers.onRenameChange}
|
||||
onSubmit={logic.handlers.onRenameSubmit}
|
||||
onCancel={logic.handlers.onRenameCancel}
|
||||
isSaving={logic.isDocumentSaving}
|
||||
canSubmit={logic.canSubmitDocument}
|
||||
inputRef={logic.attachDocumentInputRef}
|
||||
allowInlineEdit={logic.allowInlineDocumentEdit}
|
||||
onBeginEditing={logic.handlers.onRenameBegin}
|
||||
className="doc-name__primary-text"
|
||||
>
|
||||
{doc.title}
|
||||
</EditableEntryTitle>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="doc-name__tags">
|
||||
<DocumentTags
|
||||
tags={doc.tags || []}
|
||||
tagLookupById={tagLookupById}
|
||||
doc={doc}
|
||||
tagHandlers={logic.handlers.tagHandlers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{issuedLabel}</td>
|
||||
<td>{addedLabel}</td>
|
||||
</>
|
||||
)}
|
||||
</DocumentEntry>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default DocumentsListRow;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import InlineRenameInput from './InlineRenameInput';
|
||||
|
||||
interface EditableEntryTitleProps {
|
||||
isEditing: boolean;
|
||||
draftValue: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: (event?: React.SyntheticEvent) => void;
|
||||
isSaving: boolean;
|
||||
canSubmit: boolean;
|
||||
inputRef: (ref: HTMLInputElement | null) => void;
|
||||
allowInlineEdit: boolean | undefined;
|
||||
onBeginEditing: (event: React.SyntheticEvent) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EditableEntryTitle: React.FC<EditableEntryTitleProps> = ({
|
||||
isEditing,
|
||||
draftValue,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSaving,
|
||||
canSubmit,
|
||||
inputRef,
|
||||
allowInlineEdit,
|
||||
onBeginEditing,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`doc-title-edit ${className || ''}`}>
|
||||
<InlineRenameInput
|
||||
value={draftValue}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
isSaving={isSaving}
|
||||
canSubmit={canSubmit}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role={allowInlineEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineEdit ? 0 : undefined}
|
||||
onClick={onBeginEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineEdit) return;
|
||||
if (event.key === 'Enter') {
|
||||
onBeginEditing(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableEntryTitle;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import CorrespondentLinks from '../CorrespondentLinks';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface EntryCorrespondentsProps {
|
||||
correspondents: any[];
|
||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||
onCorrespondentClick?: (correspondentId: Identifier) => void;
|
||||
}
|
||||
|
||||
const EntryCorrespondents: React.FC<EntryCorrespondentsProps> = (props) => {
|
||||
if (!props.correspondents || props.correspondents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={props.correspondents}
|
||||
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
|
||||
onCorrespondentClick={props.onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryCorrespondents;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { type DragEvent } from 'react';
|
||||
|
||||
interface EntryShellHandlers {
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
onDoubleClick: (event: React.MouseEvent) => void;
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragOverCapture?: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeaveCapture?: (event: DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
interface EntryShellProps {
|
||||
component: React.ElementType;
|
||||
handlers: EntryShellHandlers;
|
||||
isSelected?: boolean;
|
||||
isDragging?: boolean;
|
||||
canDrag?: boolean;
|
||||
id: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
docId?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const EntryShell: React.FC<EntryShellProps> = ({
|
||||
component: Component,
|
||||
handlers,
|
||||
isSelected,
|
||||
isDragging,
|
||||
canDrag,
|
||||
id,
|
||||
className = '',
|
||||
children,
|
||||
docId,
|
||||
role,
|
||||
}) => {
|
||||
const classes = [className];
|
||||
if (isSelected) classes.push('selected');
|
||||
if (isDragging) classes.push('is-dragging');
|
||||
|
||||
const commonProps = {
|
||||
id,
|
||||
className: classes.join(' '),
|
||||
onClick: handlers.onClick,
|
||||
onDoubleClick: handlers.onDoubleClick,
|
||||
draggable: canDrag,
|
||||
onDragStart: handlers.onDragStart,
|
||||
onDragEnd: handlers.onDragEnd,
|
||||
onDragOver: handlers.onDragOver,
|
||||
onDragLeave: handlers.onDragLeave,
|
||||
onDrop: handlers.onDrop,
|
||||
...(docId ? { 'data-doc-id': docId } : {}),
|
||||
...(role ? { role } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Component {...commonProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryShell;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import type { DocumentViewLogic } from '../logic/useDocumentViewLogic';
|
||||
import { useFolderItemLogic } from '../features/folders/useFolderItemLogic';
|
||||
import EntryShell from './EntryShell';
|
||||
|
||||
interface FolderEntryProps {
|
||||
folder: any;
|
||||
viewLogic: DocumentViewLogic;
|
||||
component: React.ElementType;
|
||||
className?: string;
|
||||
role?: string;
|
||||
children: (logic: ReturnType<typeof useFolderItemLogic>) => React.ReactNode;
|
||||
}
|
||||
|
||||
const FolderEntry: React.FC<FolderEntryProps> = (props) => {
|
||||
const { folder, component, className, role, children, viewLogic } = props;
|
||||
const logic = useFolderItemLogic({ folder, viewLogic });
|
||||
|
||||
return (
|
||||
<EntryShell
|
||||
component={component}
|
||||
id={`folder-${folder.id}`}
|
||||
handlers={logic.handlers}
|
||||
isSelected={logic.isSelectedFolder}
|
||||
isDragging={logic.isDraggingFolder}
|
||||
canDrag={logic.canDragFolder}
|
||||
className={className}
|
||||
role={role}
|
||||
>
|
||||
{children(logic)}
|
||||
</EntryShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderEntry;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { CheckIcon, CloseIcon } from '../../components/icons';
|
||||
|
||||
interface InlineRenameInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onSubmit' | 'value'> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: (event?: React.SyntheticEvent) => void;
|
||||
isSaving?: boolean;
|
||||
canSubmit?: boolean;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InlineRenameInput: React.FC<InlineRenameInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
canSubmit = true,
|
||||
inputRef,
|
||||
className = 'doc-title-edit',
|
||||
type = 'text',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span className={className}>
|
||||
<input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCancel(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
disabled={!canSubmit || isSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
onCancel(event);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineRenameInput;
|
||||
@@ -0,0 +1,31 @@
|
||||
.tag-removal-zone {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4rem;
|
||||
background-color: var(--surface-subtle);
|
||||
border-top: 2px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
z-index: 6000002;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: all;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-removal-zone--drag-over {
|
||||
height: 6rem;
|
||||
background: linear-gradient(var(--surface-danger-subtle), var(--surface-danger-subtle)), var(--surface);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Ensure icon inherits color and overrides .icon class size */
|
||||
.tag-removal-zone__icon {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { subscribeToTagDrag } from '../features/tagging/tagTransfer';
|
||||
import { TrashIcon } from '../../components/icons';
|
||||
import './TagRemovalZone.css';
|
||||
|
||||
const TagRemovalZone: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isInteractive, setIsInteractive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to global tag drag state.
|
||||
// This avoids issues with event bubbling (stopPropagation) preventing window listeners.
|
||||
return subscribeToTagDrag((state) => {
|
||||
if (state.sourceDocId) {
|
||||
setIsVisible(true);
|
||||
// Delay interactivity to allow drag to start without immediate capture
|
||||
// and to allow dropping on documents 'behind' the zone if done quickly
|
||||
setTimeout(() => setIsInteractive(true), 200);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setIsDragOver(false);
|
||||
setIsInteractive(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
if (!isVisible || !isInteractive) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Exclusive zone
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent) => {
|
||||
if (!isInteractive) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Drop accepted. Browser sets dropEffect='move'.
|
||||
// Source component's onDragEnd will handle the data removal.
|
||||
setIsVisible(false);
|
||||
setIsDragOver(false);
|
||||
setIsInteractive(false);
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tag-removal-zone ${isDragOver ? 'tag-removal-zone--drag-over' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
style={{ pointerEvents: isInteractive ? 'all' : 'none' }}
|
||||
>
|
||||
<TrashIcon className="tag-removal-zone__icon" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagRemovalZone;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface DocumentsAssetContextValue {
|
||||
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
}
|
||||
|
||||
export const DocumentsAssetContext = createContext<DocumentsAssetContextValue>({});
|
||||
|
||||
export const useDocumentsAssetContext = () => useContext(DocumentsAssetContext);
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { createContext, useContext, type DragEvent } from 'react';
|
||||
import type { Document } from '../../types/documents';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface DocumentsCommandContextValue {
|
||||
folder: {
|
||||
onClick?: (folder: any, event: React.MouseEvent) => void;
|
||||
onSelect?: (folderId: Identifier | 'root') => void;
|
||||
onRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDrag: {
|
||||
start?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
||||
end?: (event: DragEvent<HTMLElement>) => void;
|
||||
over?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
||||
leave?: (event: DragEvent<HTMLElement>) => void;
|
||||
drop?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
|
||||
};
|
||||
};
|
||||
document: {
|
||||
onRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||
onDrag: {
|
||||
start?: (event: DragEvent<HTMLElement>, document: Document) => void;
|
||||
end?: (event: DragEvent<HTMLElement>) => void;
|
||||
};
|
||||
};
|
||||
correspondents: {
|
||||
onClick?: (correspondentId: Identifier) => void;
|
||||
};
|
||||
// General entry pointer for selection/etc
|
||||
onEntryPointer?: (entry: any, event: any) => void;
|
||||
}
|
||||
|
||||
export const DocumentsCommandContext = createContext<DocumentsCommandContextValue>({
|
||||
folder: { onDrag: {} },
|
||||
document: { onDrag: {} },
|
||||
correspondents: {},
|
||||
});
|
||||
|
||||
export const useDocumentsCommandContext = () => useContext(DocumentsCommandContext);
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import { createSafeContext } from '../../utils/createSafeContext';
|
||||
|
||||
export interface DocumentsFilterValue {
|
||||
query: string;
|
||||
searchResultIds: Array<string> | null;
|
||||
searchLoading: boolean;
|
||||
includeDescendants: boolean;
|
||||
activeTagIds: Identifier[];
|
||||
activeCorrespondentIds: Identifier[];
|
||||
isActive: boolean;
|
||||
setQuery: (value: string) => void;
|
||||
submit: () => void;
|
||||
clear: () => void;
|
||||
toggleTag: (tagId: Identifier) => void;
|
||||
toggleCorrespondent: (correspondentId?: Identifier | null) => void;
|
||||
toggleIncludeDescendants: () => void;
|
||||
}
|
||||
|
||||
const [DocumentsFilterContext, useDocumentsFilter] = createSafeContext<DocumentsFilterValue>('DocumentsFilter');
|
||||
|
||||
interface DocumentsFilterProviderProps {
|
||||
value: DocumentsFilterValue;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DocumentsFilterProvider: React.FC<DocumentsFilterProviderProps> = ({ value, children }) => (
|
||||
<DocumentsFilterContext.Provider value={value}>{children}</DocumentsFilterContext.Provider>
|
||||
);
|
||||
|
||||
export { useDocumentsFilter };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createContext, useContext, type RefObject } from 'react';
|
||||
import type { Tag, Correspondent } from '../../types/documents';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface DocumentsViewStateContextValue {
|
||||
viewId?: string | null;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
tagLookupById?: Map<Identifier, Tag> | null;
|
||||
correspondentLookupById?: Map<Identifier, Correspondent> | null;
|
||||
activeCorrespondentIdSet?: Set<Identifier> | null;
|
||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||
draggedFolderId?: Identifier | 'root' | null;
|
||||
}
|
||||
|
||||
export const DocumentsViewStateContext = createContext<DocumentsViewStateContextValue>({});
|
||||
|
||||
export const useDocumentsViewStateContext = () => useContext(DocumentsViewStateContext);
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
import type { Document, Correspondent } from '../types/documents';
|
||||
|
||||
export const resolveCorrespondents = (
|
||||
doc?: Document | null,
|
||||
lookup?: Map<Identifier, Correspondent> | null
|
||||
): Correspondent[] => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<Identifier>();
|
||||
const results: Correspondent[] = [];
|
||||
|
||||
doc.correspondents.forEach((id) => {
|
||||
if (!id) return;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
|
||||
const resolved = lookup?.get(id);
|
||||
if (resolved) {
|
||||
results.push(resolved);
|
||||
}
|
||||
});
|
||||
|
||||
return results.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/api/apiClient';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
|
||||
import { useAppDispatch, useAppState } from '../../lib/store/appState';
|
||||
|
||||
interface UseAuthManagerArgs { }
|
||||
|
||||
interface UseAuthManagerResult {
|
||||
tokenRef: MutableRefObject<string | null>;
|
||||
refreshAccessToken: () => Promise<string>;
|
||||
handleLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useAuthManager = (_: UseAuthManagerArgs = {}): UseAuthManagerResult => {
|
||||
const { token, status: appStatus } = useAppState();
|
||||
const appDispatch = useAppDispatch();
|
||||
const tokenRef = useRef<string | null>(token);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
const { showToast } = useStatusToast();
|
||||
|
||||
const refreshAccessToken = useCallback(async (): Promise<string> => {
|
||||
console.log('[Auth] Attempting to refresh access token…');
|
||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||
try {
|
||||
const data = await refreshSession();
|
||||
if (data?.access_token) {
|
||||
setAuthToken(data.access_token);
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
console.log('[Auth] Access token refreshed at', new Date().toISOString());
|
||||
return data.access_token;
|
||||
}
|
||||
throw new Error('Missing access token in refresh response');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to refresh access token', error);
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
|
||||
throw error;
|
||||
}
|
||||
}, [appDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
|
||||
initialRefreshAttemptedRef.current = true;
|
||||
console.log('[Auth] Attempting refresh at startup');
|
||||
refreshAccessToken().catch(() => { });
|
||||
}
|
||||
}, [token, appStatus, refreshAccessToken]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await logoutSession();
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||||
} finally {
|
||||
clearAuthToken();
|
||||
appDispatch({ type: 'LOGOUT' });
|
||||
showToast('Logged out.', 'info');
|
||||
}
|
||||
}, [appDispatch, showToast]);
|
||||
|
||||
return { tokenRef, refreshAccessToken, handleLogout };
|
||||
};
|
||||
|
||||
export default useAuthManager;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { MessageOptions } from '../../types/documents';
|
||||
|
||||
|
||||
interface UseBulkDocumentActionsArgs {
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>;
|
||||
handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>;
|
||||
clearDocumentSelection: () => void;
|
||||
}
|
||||
|
||||
const useBulkDocumentActions = ({
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
}: UseBulkDocumentActionsArgs) => {
|
||||
const { showToast } = useStatusToast();
|
||||
|
||||
/*
|
||||
* Bulk Deletion Logic (Handles both Documents and Folders)
|
||||
* Moved other bulk actions to useDocumentMutations to resolve circular dependencies.
|
||||
*/
|
||||
const handleDeleteSelection = useCallback(async () => {
|
||||
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||
|
||||
if (docIds.length === 0 && folderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (docIds.length) {
|
||||
parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (folderIds.length) {
|
||||
parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
const descriptor = parts.join(' and ');
|
||||
const confirmation = parts.length === 1
|
||||
? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`
|
||||
: `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`;
|
||||
|
||||
if (!window.confirm(confirmation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let docsOk = true;
|
||||
let foldersOk = true;
|
||||
|
||||
if (docIds.length) {
|
||||
docsOk = await handleDocumentsDelete(docIds, { showMessage: false });
|
||||
}
|
||||
|
||||
if (folderIds.length) {
|
||||
for (const folderId of folderIds) {
|
||||
const success = await handleFolderDelete(folderId, { showMessage: false });
|
||||
if (!success) {
|
||||
foldersOk = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!docsOk || !foldersOk) {
|
||||
showToast('Some items could not be deleted. Ensure folders are empty before deletion.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
clearDocumentSelection();
|
||||
|
||||
const successParts = [];
|
||||
if (docIds.length) {
|
||||
successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.');
|
||||
}
|
||||
if (folderIds.length) {
|
||||
successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.');
|
||||
}
|
||||
|
||||
showToast(successParts.join(' '), 'success');
|
||||
}, [
|
||||
clearDocumentSelection,
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
showToast,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleDeleteSelection,
|
||||
};
|
||||
};
|
||||
|
||||
export default useBulkDocumentActions;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useSyncExternalStore, useMemo } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import type { Correspondent } from '../../types/documents';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type CorrespondentManager from '../../lib/assets/CorrespondentManager';
|
||||
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
|
||||
interface UseCorrespondentsOptions {
|
||||
correspondentManager: CorrespondentManager;
|
||||
documentsManager?: { map: (mapper: (doc: any) => any) => void };
|
||||
}
|
||||
|
||||
const useCorrespondents = ({
|
||||
correspondentManager,
|
||||
documentsManager,
|
||||
}: UseCorrespondentsOptions) => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const correspondentsSnapshot = useSyncExternalStore<Map<Identifier, Correspondent>>(
|
||||
useCallback((cb) => correspondentManager.subscribe(cb), [correspondentManager]),
|
||||
() => correspondentManager.getSnapshot(),
|
||||
() => correspondentManager.getSnapshot(),
|
||||
);
|
||||
|
||||
const correspondents = Array.from(correspondentsSnapshot.values())
|
||||
.filter((corr): corr is Correspondent => (corr as any).id != null && (corr as any).name != null)
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
const refreshCorrespondents = useCallback(async () => {
|
||||
try {
|
||||
await correspondentManager.ensureAll(true);
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to load correspondents.');
|
||||
}
|
||||
}, [notifyApiError, correspondentManager]);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
async (correspondentId: Identifier, changes: { name?: string }) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (changes?.name != null) {
|
||||
payload.name = changes.name;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await correspondentManager.update(correspondentId, payload);
|
||||
showToast('Correspondent updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, correspondentManager, showToast],
|
||||
);
|
||||
|
||||
const handleCorrespondentCreate = useCallback(
|
||||
async ({ name }: { name?: string }) => {
|
||||
try {
|
||||
const payload = correspondentManager.buildPayload({ name });
|
||||
const data = await correspondentManager.create(payload);
|
||||
showToast('Correspondent created.', 'success');
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to create correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, correspondentManager, showToast],
|
||||
);
|
||||
|
||||
const handleCorrespondentDelete = useCallback(
|
||||
async (correspondentId: Identifier) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
try {
|
||||
await correspondentManager.delete(correspondentId);
|
||||
|
||||
const stripFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
// doc.correspondents is allowed to be Identifier[] now
|
||||
const next = doc.correspondents.filter((id: Identifier) => id !== correspondentId);
|
||||
if (next.length === doc.correspondents.length) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, correspondents: next };
|
||||
};
|
||||
|
||||
documentsManager?.map(stripFromDoc);
|
||||
|
||||
showToast('Correspondent deleted.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[documentsManager, notifyApiError, correspondentManager, showToast],
|
||||
);
|
||||
|
||||
const correspondentLookupByName = useMemo(() => {
|
||||
const map = new Map<string, Correspondent>();
|
||||
for (const correspondent of correspondents) {
|
||||
if (correspondent.name) {
|
||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [correspondents]);
|
||||
|
||||
return {
|
||||
correspondents,
|
||||
correspondentLookupById: correspondentsSnapshot,
|
||||
correspondentLookupByName,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
correspondentManager,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCorrespondents;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { Correspondent } from '../../types/documents';
|
||||
|
||||
import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../lib/api/apiClient';
|
||||
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
import type { CorrespondentsState, DocumentsState } from '../types/workspaceTypes';
|
||||
|
||||
interface UseDocumentCorrespondentMutationsArgs {
|
||||
correspondentsState: CorrespondentsState;
|
||||
documentsState: Pick<DocumentsState, 'documentsManager'>;
|
||||
}
|
||||
|
||||
const useDocumentCorrespondentMutations = ({
|
||||
correspondentsState,
|
||||
documentsState,
|
||||
}: UseDocumentCorrespondentMutationsArgs) => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const {
|
||||
correspondentManager,
|
||||
correspondentLookupByName,
|
||||
} = correspondentsState;
|
||||
|
||||
const { documentsManager } = documentsState;
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async (
|
||||
{
|
||||
documentId,
|
||||
correspondentId,
|
||||
}: { documentId: Identifier; correspondentId: Identifier; correspondent?: Correspondent | Partial<Correspondent> | null },
|
||||
{ notify = true }: { notify?: boolean } = {},
|
||||
) => {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await addDocumentCorrespondent(documentId, correspondentId);
|
||||
|
||||
documentsManager.map((doc) => {
|
||||
if (doc.id !== documentId) return undefined;
|
||||
|
||||
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
|
||||
if (current.includes(correspondentId)) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, correspondents: [...current, correspondentId] };
|
||||
});
|
||||
|
||||
if (notify) {
|
||||
showToast('Correspondent assigned.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, showToast, documentsManager],
|
||||
);
|
||||
|
||||
const handleDocumentCorrespondentDetach = useCallback(
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||
{ notify = true }: { notify?: boolean } = {},
|
||||
) => {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await removeDocumentCorrespondent(documentId, correspondentId);
|
||||
|
||||
documentsManager.map((doc) => {
|
||||
if (doc.id !== documentId) return undefined;
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const filtered = doc.correspondents.filter((id) => id !== correspondentId);
|
||||
return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
|
||||
});
|
||||
|
||||
if (notify) {
|
||||
showToast('Correspondent removed.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, showToast, documentsManager],
|
||||
);
|
||||
|
||||
const normalizeOption = (
|
||||
option: Correspondent | Partial<Correspondent> | string | null,
|
||||
): Correspondent | Partial<Correspondent> | null => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
const trimmed = option.trim();
|
||||
if (trimmed) {
|
||||
return { id: null, name: trimmed };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return option;
|
||||
};
|
||||
|
||||
const handleCorrespondentCreate = useCallback(
|
||||
async ({ name }: { name: string }) => {
|
||||
const payload = correspondentManager.buildPayload({ name });
|
||||
const data = await correspondentManager.create(payload);
|
||||
|
||||
return data;
|
||||
},
|
||||
[correspondentManager]
|
||||
);
|
||||
|
||||
const handleDocumentCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
showToast('Correspondent name is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
// Force refresh or ingest?
|
||||
if (target) {
|
||||
const asCorr = target as Correspondent;
|
||||
if (asCorr.id) {
|
||||
// Creating often yields an object we can use immediately
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to create correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.id) {
|
||||
showToast('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleDocumentCorrespondentAttach({
|
||||
documentId: document.id,
|
||||
correspondentId: target.id,
|
||||
correspondent: target.name ? target : { ...target, name: trimmed },
|
||||
});
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to assign correspondent.', 'error');
|
||||
console.error('[documents] assign correspondent failed', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
handleDocumentCorrespondentAttach,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleDocumentCorrespondentDetach, // Renamed from handleCorrespondentRemove
|
||||
handleDocumentCorrespondentAdd, // Renamed from handleCorrespondentAdd
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentCorrespondentMutations;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
|
||||
import { getEntryId, isDocumentEntry } from '../../app/entryKey';
|
||||
import {
|
||||
moveDocumentsBulk,
|
||||
moveDocumentToFolder,
|
||||
listFolderContents,
|
||||
} from '../../lib/api/apiClient';
|
||||
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
import type {
|
||||
DocumentsState,
|
||||
FolderState,
|
||||
SelectionState,
|
||||
} from '../types/workspaceTypes';
|
||||
|
||||
type FolderId = FolderIdentifier | 'root';
|
||||
type NullableFolderId = FolderId | null;
|
||||
|
||||
interface UseDocumentMoveMutationsArgs {
|
||||
documentsState: DocumentsState;
|
||||
folderState: FolderState;
|
||||
selectionState: SelectionState;
|
||||
}
|
||||
|
||||
export const useDocumentMoveMutations = ({
|
||||
documentsState,
|
||||
folderState,
|
||||
selectionState,
|
||||
}: UseDocumentMoveMutationsArgs) => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
||||
if (!value) return null;
|
||||
if (value && typeof value === 'object' && 'id' in value && value.id != null) {
|
||||
return value.id as DocumentId;
|
||||
}
|
||||
return value as DocumentId;
|
||||
};
|
||||
|
||||
const moveDocumentsToFolder = useCallback(
|
||||
async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => {
|
||||
const uniqueIds = Array.from(
|
||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
|
||||
);
|
||||
if (!uniqueIds.length) return;
|
||||
|
||||
const uniqueIdSet = new Set(uniqueIds);
|
||||
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
|
||||
const targetLabel =
|
||||
target === null ? DEFAULT_FOLDER_NAME : folderState.folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
|
||||
|
||||
const movedDocs = uniqueIds
|
||||
.map((id) => {
|
||||
const doc = documentsState.documentLookup.get(id) || null;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
|
||||
document: doc,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>;
|
||||
|
||||
const updatedDocsMap = new Map<DocumentId, Document>();
|
||||
const resolveTargetName = () => {
|
||||
if (!targetLabel) {
|
||||
return null;
|
||||
}
|
||||
const segments = String(targetLabel).split('/');
|
||||
return segments[segments.length - 1] || targetLabel;
|
||||
};
|
||||
const targetName = resolveTargetName();
|
||||
|
||||
movedDocs.forEach(({ id, document }) => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const updated: Document = {
|
||||
...document,
|
||||
folder_id: target,
|
||||
};
|
||||
if (targetLabel) {
|
||||
updated.folder_path = targetLabel;
|
||||
if (targetName) {
|
||||
updated.folder_name = targetName;
|
||||
}
|
||||
} else if (target === null) {
|
||||
updated.folder_path = DEFAULT_FOLDER_NAME;
|
||||
updated.folder_name = DEFAULT_FOLDER_NAME;
|
||||
}
|
||||
updatedDocsMap.set(id, updated);
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
if (uniqueIds.length === 1) {
|
||||
await moveDocumentToFolder(uniqueIds[0], target);
|
||||
} else {
|
||||
await moveDocumentsBulk(uniqueIds, target);
|
||||
}
|
||||
|
||||
const count = uniqueIds.length;
|
||||
const suffix = count === 1 ? '' : 's';
|
||||
showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
|
||||
|
||||
if (updatedDocsMap.size) {
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||
return undefined;
|
||||
}
|
||||
const updated = updatedDocsMap.get(doc.id as DocumentId);
|
||||
if (updated) {
|
||||
return updated;
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
if (uniqueIdSet.size) {
|
||||
const pruneRow = (rows: string[]) => rows.filter(id => !uniqueIdSet.has(getEntryId(id) as DocumentId));
|
||||
const { selectionOrderRef, selectionAnchorRef, setSelectionOrder, setFocusedDocumentId, setFocusedEntryKey, setSelectedEntries } = selectionState;
|
||||
|
||||
setSelectedEntries((prev) => pruneRow(prev));
|
||||
setSelectionOrder((prev) => pruneRow(prev));
|
||||
|
||||
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
|
||||
selectionOrderRef.current = nextSelectionOrder;
|
||||
|
||||
if (
|
||||
selectionAnchorRef.current &&
|
||||
isDocumentEntry(selectionAnchorRef.current) &&
|
||||
uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId)
|
||||
) {
|
||||
selectionAnchorRef.current = null;
|
||||
}
|
||||
if (
|
||||
selectionState.focusedDocumentId &&
|
||||
uniqueIdSet.has(selectionState.focusedDocumentId)
|
||||
) {
|
||||
setFocusedDocumentId(null);
|
||||
}
|
||||
if (
|
||||
selectionState.focusedEntryKey &&
|
||||
isDocumentEntry(selectionState.focusedEntryKey) &&
|
||||
uniqueIdSet.has(getEntryId(selectionState.focusedEntryKey) as DocumentId)
|
||||
) {
|
||||
setFocusedEntryKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFolderId && targetFolderId !== folderState.selectedFolder) {
|
||||
await listFolderContents(targetFolderId as FolderId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
||||
notifyApiError(error, message);
|
||||
}
|
||||
},
|
||||
[
|
||||
documentsState,
|
||||
folderState.folderLabelMap,
|
||||
folderState.selectedFolder,
|
||||
selectionState,
|
||||
notifyApiError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
return { moveDocumentsToFolder };
|
||||
};
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
|
||||
import {
|
||||
queueDocumentReanalysis,
|
||||
trashDocument,
|
||||
updateDocument,
|
||||
createTag,
|
||||
bulkTagDocuments,
|
||||
bulkReanalyzeDocuments,
|
||||
assignCorrespondentsBulk,
|
||||
} from '../../lib/api/apiClient';
|
||||
import type { DocumentId, FolderId as FolderIdentifier, Identifier } from '../../types/identifiers';
|
||||
import type { Document, MessageOptions } from '../../types/documents';
|
||||
import { useDocumentTagMutations } from './useDocumentTagMutations';
|
||||
import { useDocumentMoveMutations } from './useDocumentMoveMutations';
|
||||
import type {
|
||||
DocumentsState,
|
||||
FolderState,
|
||||
SelectionState,
|
||||
TagsState,
|
||||
CorrespondentsState,
|
||||
} from '../types/workspaceTypes';
|
||||
import type { Tag, Correspondent } from '../../types/documents';
|
||||
import useDocumentCorrespondentMutations from './useDocumentCorrespondentMutations';
|
||||
|
||||
type FolderId = FolderIdentifier | 'root';
|
||||
type NullableFolderId = FolderId | null;
|
||||
|
||||
interface DocumentTagExtras {
|
||||
option?: Tag | null;
|
||||
input?: { value?: string } | null;
|
||||
}
|
||||
|
||||
interface BulkTagOperationArgs {
|
||||
labels: string[];
|
||||
action: 'add' | 'remove';
|
||||
documentIds?: Identifier[];
|
||||
}
|
||||
|
||||
interface BulkTagOperationResult {
|
||||
ok: boolean;
|
||||
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||
label?: string;
|
||||
tagCount?: number;
|
||||
docsCount?: number;
|
||||
}
|
||||
|
||||
type CorrespondentAssignment = {
|
||||
correspondent_id?: Identifier;
|
||||
};
|
||||
|
||||
interface UseDocumentMutationsArgs {
|
||||
documentsState: DocumentsState;
|
||||
folderState: FolderState;
|
||||
selectionState: SelectionState;
|
||||
tagsState: TagsState;
|
||||
correspondentsState: CorrespondentsState;
|
||||
closeDocumentViewer: () => void;
|
||||
viewerDocumentId?: DocumentId | null;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsResult {
|
||||
moveDocumentsToFolder: (
|
||||
documentIds: Array<DocumentId | Document>,
|
||||
targetFolderId?: NullableFolderId,
|
||||
) => Promise<void>;
|
||||
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
|
||||
handleDocumentsDelete: (
|
||||
documentIds: DocumentId[],
|
||||
options?: MessageOptions,
|
||||
) => Promise<boolean>;
|
||||
handleDocumentTagAdd: (
|
||||
document: Document,
|
||||
label: string,
|
||||
extras?: DocumentTagExtras | null,
|
||||
) => Promise<void>;
|
||||
handleDocumentTagAttach: (documentId: DocumentId, tagId: DocumentId) => Promise<boolean>;
|
||||
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
|
||||
handleDocumentIssuedUpdate: (
|
||||
documentId: DocumentId,
|
||||
nextIssuedDate: number | null,
|
||||
) => Promise<boolean>;
|
||||
handleDocumentTagDetach: (
|
||||
documentId?: DocumentId,
|
||||
tagId?: DocumentId,
|
||||
) => Promise<boolean>;
|
||||
handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>;
|
||||
handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise<boolean>;
|
||||
handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => Promise<void>;
|
||||
handleBulkCorrespondentAdd: (args: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkCorrespondentRemove: (args: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkTagAddFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkTagRemoveFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkSelectionReanalyze: (documentIdsOverride?: Identifier[] | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const useDocumentMutations = ({
|
||||
documentsState,
|
||||
folderState,
|
||||
selectionState,
|
||||
tagsState,
|
||||
correspondentsState,
|
||||
closeDocumentViewer,
|
||||
viewerDocumentId,
|
||||
resolveTargetDocumentIds,
|
||||
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const { moveDocumentsToFolder } = useDocumentMoveMutations({
|
||||
documentsState,
|
||||
folderState,
|
||||
selectionState,
|
||||
});
|
||||
|
||||
const {
|
||||
handleDocumentTagAdd,
|
||||
handleDocumentTagAttach,
|
||||
handleDocumentTagDetach,
|
||||
} = useDocumentTagMutations({
|
||||
tagsState,
|
||||
documentsState: { documentsManager: documentsState.documentsManager },
|
||||
});
|
||||
|
||||
const {
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleDocumentCorrespondentDetach,
|
||||
handleDocumentCorrespondentAdd,
|
||||
} = useDocumentCorrespondentMutations({
|
||||
correspondentsState,
|
||||
documentsState: { documentsManager: documentsState.documentsManager },
|
||||
});
|
||||
|
||||
const handleThumbnailRegeneration = useCallback(
|
||||
async (documentId: DocumentId) => {
|
||||
try {
|
||||
await queueDocumentReanalysis(documentId);
|
||||
showToast('Analysis queued.', 'info');
|
||||
// Close preview if it's the current one to allow refresh?
|
||||
if (viewerDocumentId === documentId) {
|
||||
closeDocumentViewer();
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to queue analysis.');
|
||||
}
|
||||
},
|
||||
[closeDocumentViewer, notifyApiError, viewerDocumentId, showToast],
|
||||
);
|
||||
|
||||
const handleDocumentsDelete = useCallback(
|
||||
async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => {
|
||||
if (!documentIds?.length) return false;
|
||||
|
||||
// Optimistic update could happen here but usually we wait for standardized confirmation
|
||||
// However workspace expects mutation here.
|
||||
try {
|
||||
await Promise.all(documentIds.map((id) => trashDocument(id)));
|
||||
|
||||
// Remove from local state and manager
|
||||
documentsState.documentsManager.remove(documentIds);
|
||||
|
||||
if (showMessage) {
|
||||
const count = documentIds.length;
|
||||
const suffix = count === 1 ? '' : 's';
|
||||
showToast(`${count} document${suffix} deleted.`, 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
documentsState,
|
||||
notifyApiError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentTitleUpdate = useCallback(
|
||||
async (documentId: DocumentId, nextTitle: string) => {
|
||||
const trimmed = nextTitle?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
showToast('Document title cannot be empty.', 'error');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const data = await updateDocument(documentId, { title: trimmed });
|
||||
const updatedDocument = documentsState.extractDocumentFromResponse?.(data);
|
||||
|
||||
if (updatedDocument && documentsState.ingestDocuments) {
|
||||
documentsState.ingestDocuments([updatedDocument]);
|
||||
} else {
|
||||
documentsState.documentsManager.update(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, title: trimmed };
|
||||
});
|
||||
}
|
||||
|
||||
showToast('Document title updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
documentsState,
|
||||
notifyApiError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentIssuedUpdate = useCallback(
|
||||
async (documentId: DocumentId, nextIssuedDate: number | null) => {
|
||||
const payload = { issued_at: nextIssuedDate || null };
|
||||
try {
|
||||
const data = await updateDocument(documentId, payload);
|
||||
const updatedDocument = documentsState.extractDocumentFromResponse?.(data);
|
||||
|
||||
if (updatedDocument && documentsState.ingestDocuments) {
|
||||
documentsState.ingestDocuments([updatedDocument]);
|
||||
} else {
|
||||
documentsState.documentsManager.update(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, issued_at: payload.issued_at };
|
||||
});
|
||||
}
|
||||
|
||||
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
||||
showToast(message, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
documentsState,
|
||||
notifyApiError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||
if (!labels?.length) return { ok: false, reason: 'no-labels' };
|
||||
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds?.length) return { ok: false, reason: 'no-selection' };
|
||||
|
||||
const existingTags = tagsState.tags || [];
|
||||
const tagMap = new Map(existingTags.map((t) => [t.label, t]));
|
||||
|
||||
const tagsToProcess: Tag[] = [];
|
||||
const labelsToCreate: string[] = [];
|
||||
|
||||
for (const lbl of labels) {
|
||||
const tag = tagMap.get(lbl);
|
||||
if (tag) {
|
||||
tagsToProcess.push(tag);
|
||||
} else if (action === 'add') {
|
||||
labelsToCreate.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lbl of labelsToCreate) {
|
||||
try {
|
||||
// Use API directly to create tag
|
||||
const created = await createTag({ label: lbl, color: '#c0c0c0' });
|
||||
if (created) {
|
||||
tagsToProcess.push(created as Tag);
|
||||
if (tagsState.tagManager && typeof tagsState.tagManager.ingest === 'function') {
|
||||
tagsState.tagManager.ingest([created as Tag]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create tag', lbl, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tagsToProcess.length && action === 'add') {
|
||||
return { ok: false, reason: 'tag-missing' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tagIds = tagsToProcess.map(t => t.id);
|
||||
await bulkTagDocuments({ document_ids: targetIds, tag_ids: tagIds, action });
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (!targetIds.includes(doc.id)) return undefined;
|
||||
const oldTags = doc.tags || [];
|
||||
let newTags = [...oldTags];
|
||||
const processIds = new Set(tagIds);
|
||||
|
||||
if (action === 'add') {
|
||||
const currentIds = new Set(oldTags);
|
||||
tagIds.forEach(tid => {
|
||||
if (!currentIds.has(tid)) newTags.push(tid);
|
||||
});
|
||||
} else {
|
||||
newTags = newTags.filter(tid => !processIds.has(tid));
|
||||
}
|
||||
return { ...doc, tags: newTags };
|
||||
});
|
||||
|
||||
return { ok: true, docsCount: targetIds.length, tagCount: tagsToProcess.length, label: labels[0] };
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Bulk tag operation failed');
|
||||
return { ok: false, reason: 'request-failed' };
|
||||
}
|
||||
},
|
||||
[documentsState, resolveTargetDocumentIds, tagsState, notifyApiError]
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = label || input?.value?.trim();
|
||||
if (!text) return;
|
||||
|
||||
const res = await bulkTagOperation({ labels: [text], action: 'add', documentIds });
|
||||
if (res.ok) {
|
||||
showToast(`Added tag "${text}" to ${res.docsCount} documents.`, 'success');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}, [bulkTagOperation, showToast]);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = label || input?.value?.trim();
|
||||
if (!text) return;
|
||||
|
||||
const res = await bulkTagOperation({ labels: [text], action: 'remove', documentIds });
|
||||
if (res.ok) {
|
||||
showToast(`Removed tag "${text}" from ${res.docsCount} documents.`, 'success');
|
||||
}
|
||||
}, [bulkTagOperation, showToast]);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(async (documentIdsOverride?: Identifier[] | null) => {
|
||||
const ids = resolveTargetDocumentIds(documentIdsOverride || undefined);
|
||||
if (!ids.length) {
|
||||
showToast('No documents selected.', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bulkReanalyzeDocuments({ document_ids: ids });
|
||||
showToast(`Queued reanalysis for ${ids.length} documents.`, 'success');
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to queue reanalysis');
|
||||
}
|
||||
}, [resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
const handleBulkCorrespondentAdd = useCallback(async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = name || input?.value?.trim();
|
||||
if (!text) return;
|
||||
const ids = resolveTargetDocumentIds(documentIds);
|
||||
if (!ids.length) return;
|
||||
|
||||
const { correspondentManager, correspondentLookupByName } = correspondentsState;
|
||||
const normalized = text.trim();
|
||||
|
||||
let corr = correspondentLookupByName?.get(normalized.toLowerCase());
|
||||
|
||||
if (!corr) {
|
||||
try {
|
||||
// Create new correspondent
|
||||
const payload = correspondentManager.buildPayload({ name: normalized });
|
||||
corr = await correspondentManager.create(payload);
|
||||
} catch (e) {
|
||||
console.error('Failed to create correspondent', e);
|
||||
showToast('Failed to create correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!corr) {
|
||||
showToast('Correspondent could not be found or created.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignCorrespondentsBulk({
|
||||
document_ids: ids,
|
||||
assignments: [{ correspondent_id: corr.id }],
|
||||
action: 'add'
|
||||
});
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (ids.includes(doc.id)) {
|
||||
const current = doc.correspondents || [];
|
||||
if (corr?.id && !current.includes(corr.id)) {
|
||||
return { ...doc, correspondents: [...current, corr.id] };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
showToast(`Assigned "${corr.name}" to ${ids.length} documents.`, 'success');
|
||||
if (input) input.value = '';
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to assign correspondent');
|
||||
}
|
||||
}, [documentsState, correspondentsState, resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(async ({ documentIds }: { documentIds?: Identifier[] }) => {
|
||||
const ids = resolveTargetDocumentIds(documentIds);
|
||||
if (!ids.length) return;
|
||||
|
||||
const correspondentsToRemove = new Set<Identifier>();
|
||||
ids.forEach(docId => {
|
||||
const doc = documentsState.documentLookup.get(docId);
|
||||
if (doc?.correspondents?.length) {
|
||||
doc.correspondents.forEach(cId => correspondentsToRemove.add(cId));
|
||||
}
|
||||
});
|
||||
|
||||
if (correspondentsToRemove.size === 0) {
|
||||
showToast('No correspondents found to remove.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const assignments = Array.from(correspondentsToRemove).map(id => ({ correspondent_id: id }));
|
||||
|
||||
try {
|
||||
await assignCorrespondentsBulk({
|
||||
document_ids: ids,
|
||||
assignments,
|
||||
action: 'remove'
|
||||
});
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (ids.includes(doc.id)) {
|
||||
// Remove any of the targeted correspondents from the document
|
||||
const current = doc.correspondents || [];
|
||||
const newCorrespondents = current.filter(cId => !correspondentsToRemove.has(cId));
|
||||
if (current.length !== newCorrespondents.length) {
|
||||
return { ...doc, correspondents: newCorrespondents };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
showToast(`Removed correspondents from ${ids.length} documents.`, 'success');
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to remove correspondents');
|
||||
}
|
||||
}, [documentsState, resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
return {
|
||||
moveDocumentsToFolder,
|
||||
handleThumbnailRegeneration,
|
||||
handleDocumentsDelete,
|
||||
handleDocumentTagAdd,
|
||||
handleDocumentTagAttach,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTagDetach,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleDocumentCorrespondentDetach,
|
||||
handleDocumentCorrespondentAdd,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentMutations;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { DocumentId } from '../../types/identifiers';
|
||||
import type { Document, Tag } from '../../types/documents';
|
||||
import {
|
||||
addDocumentTags,
|
||||
createTag,
|
||||
deleteDocumentTag,
|
||||
} from '../../lib/api/apiClient';
|
||||
import type { TagsState, DocumentsState } from '../types/workspaceTypes';
|
||||
|
||||
interface DocumentTagExtras {
|
||||
option?: Tag | null;
|
||||
input?: { value?: string } | null;
|
||||
}
|
||||
|
||||
interface UseDocumentTagMutationsArgs {
|
||||
tagsState: TagsState;
|
||||
documentsState: Pick<DocumentsState, 'documentsManager'>;
|
||||
}
|
||||
|
||||
export const useDocumentTagMutations = ({
|
||||
tagsState,
|
||||
documentsState,
|
||||
}: UseDocumentTagMutationsArgs) => {
|
||||
// Note: Toasts are handled by the caller, e.g. useDetailWorkspace or ResultQueue.
|
||||
|
||||
const attachTagToDocument = useCallback(
|
||||
async ({
|
||||
documentId,
|
||||
tag,
|
||||
}: {
|
||||
documentId?: DocumentId;
|
||||
tag?: Tag | null;
|
||||
}) => {
|
||||
if (!documentId || !tag?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await addDocumentTags(documentId, [tag.id]);
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (doc.id !== documentId) {
|
||||
return undefined;
|
||||
}
|
||||
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
|
||||
if (currentTags.includes(tag.id)) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, tags: [...currentTags, tag.id] };
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
[documentsState],
|
||||
);
|
||||
|
||||
const handleDocumentTagAdd = useCallback(
|
||||
async (document: Document, label: string, extras?: DocumentTagExtras | null) => {
|
||||
const normalizedLabel = tagsState.tagManager.normalizeLabel(label);
|
||||
const optionCandidate = extras?.option ?? null;
|
||||
const input = extras?.input ?? null;
|
||||
|
||||
let tag: Tag | null = null;
|
||||
// Lookup via ID
|
||||
if (optionCandidate && optionCandidate.id) {
|
||||
tag = tagsState.tagLookupById.get(optionCandidate.id) || (optionCandidate as Tag);
|
||||
}
|
||||
// Lookup via Label if not found
|
||||
if (!tag) {
|
||||
const knownTags = Array.from(tagsState.tagLookupById.values());
|
||||
tag = knownTags.find((item) => item.label?.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
// Create tag if needed. Errors bubble up.
|
||||
if (!tag) {
|
||||
const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
|
||||
const data = await createTag(payload);
|
||||
tag = data as Tag;
|
||||
// Ingest new tag into manager to ensure it's available
|
||||
tagsState.tagManager.ingest([tag]);
|
||||
await tagsState.refreshTags();
|
||||
}
|
||||
await attachTagToDocument({
|
||||
documentId: document.id as DocumentId,
|
||||
tag,
|
||||
});
|
||||
if (input && typeof input === 'object' && 'value' in input) {
|
||||
(input as { value?: string }).value = '';
|
||||
}
|
||||
},
|
||||
[tagsState, attachTagToDocument],
|
||||
);
|
||||
|
||||
const handleDocumentTagAttach = useCallback(
|
||||
async (documentId: DocumentId, tagId: DocumentId) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolveTagForCache = (): Tag | null => {
|
||||
const lookupTag = tagsState.tagLookupById.get(tagId);
|
||||
if (!lookupTag || lookupTag.id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lookupTag;
|
||||
};
|
||||
|
||||
const resolvedTag = resolveTagForCache();
|
||||
return attachTagToDocument({
|
||||
documentId,
|
||||
tag: resolvedTag,
|
||||
});
|
||||
},
|
||||
[
|
||||
attachTagToDocument,
|
||||
tagsState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentTagDetach = useCallback(
|
||||
async (documentId?: DocumentId, tagId?: DocumentId) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteDocumentTag(documentId, tagId);
|
||||
// Inlined applyTagRemovalToCaches logic
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (doc.id !== documentId) {
|
||||
return undefined;
|
||||
}
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
// Filter IDs
|
||||
const nextTags = doc.tags.filter((id) => id !== tagId);
|
||||
if (nextTags.length === doc.tags.length) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, tags: nextTags };
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[documentsState],
|
||||
);
|
||||
|
||||
return {
|
||||
handleDocumentTagAdd,
|
||||
handleDocumentTagAttach,
|
||||
handleDocumentTagDetach,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import DocumentsManager from '../DocumentsManager';
|
||||
import type { DocumentId } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
|
||||
interface UseDocumentsOptions {
|
||||
fetchDocumentById?: (id: DocumentId) => Promise<Document | null>;
|
||||
}
|
||||
|
||||
const useDocuments = ({
|
||||
fetchDocumentById,
|
||||
}: UseDocumentsOptions) => {
|
||||
const managerRef = useRef(
|
||||
new DocumentsManager<Document>(fetchDocumentById),
|
||||
);
|
||||
// Store only IDs in local state
|
||||
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current.setFetcher(fetchDocumentById);
|
||||
}, [fetchDocumentById]);
|
||||
|
||||
// Subscribe to the manager for reactive updates
|
||||
const managerSnapshot = useSyncExternalStore(
|
||||
useCallback((cb) => managerRef.current.subscribe(cb), []),
|
||||
() => managerRef.current.getSnapshot(),
|
||||
() => managerRef.current.getSnapshot(),
|
||||
);
|
||||
|
||||
// Derive the full document objects from IDs + Snapshot
|
||||
const documents = useMemo(() => {
|
||||
if (!documentIds.length) return [];
|
||||
|
||||
// Efficiently map IDs to current document objects from the snapshot
|
||||
// If an ID is missing in the snapshot (unlikely if ingested correctly), return null/undefined and filter
|
||||
return documentIds
|
||||
.map(id => managerSnapshot.get(id))
|
||||
.filter((doc): doc is Document => Boolean(doc));
|
||||
}, [documentIds, managerSnapshot]);
|
||||
|
||||
// Keep a ref to the latest documents to avoid setDocuments dependency
|
||||
const documentsRef = useRef(documents);
|
||||
useEffect(() => {
|
||||
documentsRef.current = documents;
|
||||
}, [documents]);
|
||||
|
||||
const setDocuments = useCallback(
|
||||
(value: Document[] | ((prev: Document[]) => Document[])) => {
|
||||
// Support functional updates using the current derived documents as the previous state.
|
||||
// Use ref to avoid re-creating this callback when documents change.
|
||||
const prevDocs = documentsRef.current;
|
||||
const newDocs = typeof value === 'function' ? value(prevDocs) : value;
|
||||
|
||||
if (!Array.isArray(newDocs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { canonical } = managerRef.current.ingest(newDocs);
|
||||
const newIds = canonical.map(d => d.id as DocumentId).filter(Boolean);
|
||||
setDocumentIds(newIds);
|
||||
},
|
||||
[] // Stable callback
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
setDocuments,
|
||||
documentsManager: managerRef.current,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocuments;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
import { MutableRefObject, useCallback, useSyncExternalStore } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import type { TagId, TenantId } from '../../types/identifiers';
|
||||
import type { Tag } from '../../types/documents';
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
import TagManager from '../../lib/assets/TagManager';
|
||||
|
||||
interface UseTagsOptions {
|
||||
tagManager: TagManager;
|
||||
tenantIdRef: MutableRefObject<TenantId | null>;
|
||||
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
|
||||
documentsManager?: { map: (mapper: (doc: any) => any) => void };
|
||||
}
|
||||
|
||||
interface UseTagsResult {
|
||||
tags: Tag[];
|
||||
tagLookupById: Map<TagId, Tag>;
|
||||
refreshTags: () => Promise<void>;
|
||||
handleTagUpdate: (tagId: TagId, changes: { label?: string; color?: string | null }) => Promise<boolean>;
|
||||
handleTagCreate: (payload?: { label?: string; color?: string | null }) => Promise<void>;
|
||||
handleTagDelete: (tagId: TagId) => Promise<boolean>;
|
||||
tagManager: TagManager;
|
||||
}
|
||||
|
||||
const useTags = ({
|
||||
tagManager,
|
||||
setActiveTagFilters,
|
||||
documentsManager,
|
||||
}: UseTagsOptions): UseTagsResult => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const tagsSnapshot = useSyncExternalStore<Map<TagId, Tag>>(
|
||||
useCallback((cb) => tagManager.subscribe(cb), [tagManager]),
|
||||
() => tagManager.getSnapshot(),
|
||||
() => tagManager.getSnapshot(),
|
||||
);
|
||||
|
||||
const tags = Array.from(tagsSnapshot.values())
|
||||
.filter((tag): tag is Tag => (tag as any).id != null && (tag as any).label != null) // Ensure strict adherence
|
||||
.sort((a, b) =>
|
||||
(a.label || '').localeCompare(b.label || '')
|
||||
);
|
||||
|
||||
const refreshTags = useCallback(async () => {
|
||||
try {
|
||||
await tagManager.ensureAll(true);
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to load tags.');
|
||||
}
|
||||
}, [notifyApiError, tagManager]);
|
||||
|
||||
const handleTagUpdate = useCallback(
|
||||
async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
const payload: Record<string, string> = {};
|
||||
if (changes?.label != null) {
|
||||
payload.label = changes.label;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
||||
payload.color = changes.color || ''; // API might behave differently if color is literally null, usually string expected
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await tagManager.update(tagId, payload as any);
|
||||
showToast('Tag updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, tagManager, showToast],
|
||||
);
|
||||
|
||||
const handleTagCreate = useCallback(
|
||||
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
|
||||
const payload = tagManager.buildPayload({ label, color });
|
||||
try {
|
||||
const newTag = await tagManager.create(payload);
|
||||
showToast('Tag created.', 'success');
|
||||
return newTag;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to create tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[notifyApiError, showToast, tagManager],
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
async (tagId: TagId) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
try {
|
||||
await tagManager.delete(tagId);
|
||||
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||
|
||||
const stripTagFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
const nextTags = doc.tags.filter((tag: Tag) => tag.id !== tagId);
|
||||
if (nextTags.length === doc.tags.length) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, tags: nextTags };
|
||||
};
|
||||
|
||||
documentsManager?.map(stripTagFromDoc);
|
||||
showToast('Tag deleted.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete tag.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[documentsManager, notifyApiError, setActiveTagFilters, showToast, tagManager],
|
||||
);
|
||||
|
||||
return {
|
||||
tags,
|
||||
tagLookupById: tagsSnapshot,
|
||||
refreshTags,
|
||||
handleTagUpdate,
|
||||
handleTagCreate,
|
||||
handleTagDelete,
|
||||
tagManager,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTags;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { TenantId } from '../../types/identifiers';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import { useAppDispatch } from '../../lib/store/appState';
|
||||
|
||||
import { api, listTenants, switchTenant } from '../../lib/api/apiClient';
|
||||
|
||||
import useNotifyApiError from '../../hooks/useNotifyApiError';
|
||||
|
||||
interface TenantOption {
|
||||
id?: TenantId;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface UseTenantManagerOptions {
|
||||
currentTenantId: TenantId | null;
|
||||
handleDocumentsViewModeChange: (mode: string) => void;
|
||||
}
|
||||
|
||||
const useTenantManager = ({
|
||||
currentTenantId,
|
||||
handleDocumentsViewModeChange,
|
||||
}: UseTenantManagerOptions) => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
const navigate = useNavigate();
|
||||
const appDispatch = useAppDispatch();
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
|
||||
const requestedTenantId = tenantOption?.id ?? null;
|
||||
|
||||
// 1. Guard Clauses
|
||||
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Refresh Logic
|
||||
if (refreshOnly) {
|
||||
const data = await listTenants();
|
||||
appDispatch({ type: 'SET_TENANTS', tenants: data });
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Switch Logic
|
||||
const data = await switchTenant(requestedTenantId);
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Missing access token in tenant switch response.');
|
||||
}
|
||||
|
||||
// 4. Reset UI to safe state BEFORE updating global auth
|
||||
// This prevents old components from reacting to state changes.
|
||||
|
||||
|
||||
// 5. Update Global State IMMEDIATELY
|
||||
// Don't wait for navigation. Data consistency comes first.
|
||||
handleDocumentsViewModeChange('list');
|
||||
api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
|
||||
if (Array.isArray(data?.tenants)) {
|
||||
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
||||
}
|
||||
|
||||
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||
showToast(`Switched to ${tenantLabel}.`, 'info');
|
||||
|
||||
// 5. Handle UI/Navigation changes AFTER state is secure
|
||||
navigate('/documents', { replace: true });
|
||||
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to switch tenant.');
|
||||
}
|
||||
},
|
||||
[
|
||||
appDispatch,
|
||||
currentTenantId,
|
||||
handleDocumentsViewModeChange,
|
||||
navigate,
|
||||
notifyApiError,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleTenantSelect };
|
||||
};
|
||||
|
||||
export default useTenantManager;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { DocumentId, FolderId } from '../../types/identifiers';
|
||||
import type { Document } from '../../types/documents';
|
||||
import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey';
|
||||
|
||||
interface UseWorkspaceViewDataArgs {
|
||||
documents: Document[];
|
||||
documentLookup: Map<DocumentId, Document>;
|
||||
searchResultIds: DocumentId[] | null;
|
||||
showingSearchResults: boolean;
|
||||
currentSubfolders: any[];
|
||||
selectedFolder: FolderId;
|
||||
}
|
||||
|
||||
const useWorkspaceViewData = ({
|
||||
documents,
|
||||
documentLookup,
|
||||
searchResultIds,
|
||||
showingSearchResults,
|
||||
currentSubfolders,
|
||||
}: UseWorkspaceViewDataArgs) => {
|
||||
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
|
||||
a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
|
||||
if (showingSearchResults && Array.isArray(searchResultIds)) {
|
||||
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
|
||||
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
|
||||
return;
|
||||
}
|
||||
|
||||
const folderIds = documents
|
||||
.map((doc) => (doc?.id ?? null) as DocumentId | null)
|
||||
.filter((id): id is DocumentId => id != null);
|
||||
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
|
||||
}, [showingSearchResults, searchResultIds, documents]);
|
||||
|
||||
const viewDocuments = useMemo(
|
||||
() =>
|
||||
visibleDocumentIds
|
||||
.map((id) => documentLookup.get(id) || null)
|
||||
.filter((doc): doc is Document => Boolean(doc)),
|
||||
[visibleDocumentIds, documentLookup],
|
||||
);
|
||||
|
||||
const visibleDocumentKeys = useMemo(
|
||||
() => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean),
|
||||
[visibleDocumentIds],
|
||||
);
|
||||
|
||||
const visibleFolderKeys = useMemo(
|
||||
() =>
|
||||
showingSearchResults
|
||||
? []
|
||||
: (currentSubfolders || [])
|
||||
.map((folder: any) => createFolderEntryKey(folder.id))
|
||||
.filter(Boolean),
|
||||
[showingSearchResults, currentSubfolders],
|
||||
);
|
||||
|
||||
const visibleEntryKeys = useMemo(
|
||||
() => [...visibleFolderKeys, ...visibleDocumentKeys],
|
||||
[visibleFolderKeys, visibleDocumentKeys],
|
||||
);
|
||||
|
||||
const visibleEntryKeySet = useMemo(
|
||||
() => new Set(visibleEntryKeys),
|
||||
[visibleEntryKeys],
|
||||
);
|
||||
|
||||
return {
|
||||
viewDocuments,
|
||||
visibleDocumentIds,
|
||||
visibleEntryKeys,
|
||||
visibleEntryKeySet,
|
||||
};
|
||||
};
|
||||
|
||||
export default useWorkspaceViewData;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Document } from '../types/documents';
|
||||
|
||||
export const resolveDocumentDownloadHref = (document?: Document | null): string | null => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
const download = document.current_version?.download;
|
||||
if (!download?.url) {
|
||||
return null;
|
||||
}
|
||||
if (download.expires_at && download.expires_at <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
return download.url;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { type DragEvent } from 'react';
|
||||
import { isTagTransferEvent } from '../tagging/tagTransfer';
|
||||
import type { DocumentViewLogic } from '../../logic/useDocumentViewLogic';
|
||||
import { useDocumentsCommandContext } from '../../context/DocumentsCommandContext';
|
||||
import { useDocumentsViewStateContext } from '../../context/DocumentsViewStateContext';
|
||||
|
||||
interface UseFolderItemLogicProps {
|
||||
folder: any;
|
||||
viewLogic: DocumentViewLogic;
|
||||
}
|
||||
|
||||
export const useFolderItemLogic = (props: UseFolderItemLogicProps) => {
|
||||
const { folder, viewLogic } = props;
|
||||
const {
|
||||
draggedFolderId,
|
||||
} = useDocumentsViewStateContext();
|
||||
|
||||
const {
|
||||
folder: {
|
||||
onClick: onFolderClick,
|
||||
onSelect: onFolderSelect,
|
||||
onRename: onFolderRename,
|
||||
onDrag: {
|
||||
start: onFolderDragStart,
|
||||
end: onFolderDragEnd,
|
||||
over: onFolderDragOver,
|
||||
leave: onFolderDragLeave,
|
||||
drop: onFolderDrop,
|
||||
}
|
||||
}
|
||||
|
||||
} = useDocumentsCommandContext();
|
||||
|
||||
const {
|
||||
selectedFolderIdsSet,
|
||||
totalSelectionCount,
|
||||
folderRename: {
|
||||
editingId: editingFolderId,
|
||||
draftValue: folderDraft,
|
||||
setDraftValue: setFolderDraft,
|
||||
beginEditing: beginFolderEditing,
|
||||
cancelEditing: cancelFolderEditing,
|
||||
submitEditing: submitFolderEditing,
|
||||
savingId: savingFolderId,
|
||||
attachInputRef: attachFolderInputRef,
|
||||
},
|
||||
} = viewLogic;
|
||||
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
const isFolderEditing = editingFolderId === folder.id;
|
||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||
const isFolderSaving = savingFolderId === folder.id;
|
||||
const canSubmitFolder =
|
||||
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
||||
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
||||
|
||||
const handlers = {
|
||||
onClick: (event: React.MouseEvent) => onFolderClick?.(folder, event),
|
||||
onDoubleClick: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
},
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => {
|
||||
if (isTagTransferEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
onFolderDragOver?.(event, folder.id);
|
||||
},
|
||||
onDragLeave: onFolderDragLeave,
|
||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||
if (isTagTransferEvent(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onFolderDrop?.(event, folder.id);
|
||||
},
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
},
|
||||
onDragEnd: (event: DragEvent<HTMLElement>) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
},
|
||||
onRenameChange: setFolderDraft,
|
||||
onRenameSubmit: () => submitFolderEditing(folder),
|
||||
onRenameCancel: (event?: React.SyntheticEvent) => cancelFolderEditing(event),
|
||||
onRenameBegin: (event: React.SyntheticEvent) => {
|
||||
if (!allowInlineFolderEdit) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
canDragFolder,
|
||||
isDraggingFolder,
|
||||
isSelectedFolder,
|
||||
isFolderEditing,
|
||||
folderDraftValue,
|
||||
isFolderSaving,
|
||||
canSubmitFolder,
|
||||
allowInlineFolderEdit,
|
||||
attachFolderInputRef,
|
||||
handlers,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user