Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 13s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 12s

This commit is contained in:
2025-12-17 13:37:31 +01:00
commit 3c9a9fc060
399 changed files with 80777 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
/dist
/.env.local
+27
View File
@@ -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;"]
+56
View File
@@ -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/).
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: 'defaults',
},
],
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
'@babel/preset-typescript',
],
};
+59
View File
@@ -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 "$@"
+86
View File
@@ -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: '^_',
},
],
},
},
];
+11580
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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"
}
}
+112
View File
@@ -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;
+16
View File
@@ -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;
+535
View File
@@ -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;
+406
View File
@@ -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,
};
};
+127
View File
@@ -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;
+208
View File
@@ -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 };
+25
View File
@@ -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);
};
+52
View File
@@ -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,
};
};
+287
View File
@@ -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,
};
};
+151
View File
@@ -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;
+114
View File
@@ -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,
};
};
+298
View File
@@ -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;
+49
View File
@@ -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;
+227
View File
@@ -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,
};
};
+117
View File
@@ -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,
};
};
+238
View File
@@ -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 };
};
+64
View File
@@ -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;
};
+11
View File
@@ -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

+19
View File
@@ -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

+421
View File
@@ -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;
+63
View File
@@ -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;
+277
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export const composeClassName = (base: string, extra?: string | null): string =>
extra ? `${base} ${extra}` : base;
+206
View File
@@ -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>
);
+309
View File
@@ -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;
+4
View File
@@ -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';
+1
View File
@@ -0,0 +1 @@
export const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
+3
View File
@@ -0,0 +1,3 @@
export const DB_NAME = 'papercrate_desk';
export const DB_VERSION = 1;
export const LAYOUT_STORE = 'layouts';
+20
View File
@@ -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;
}, {});
+23
View File
@@ -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',
};
+32
View File
@@ -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;
+28
View File
@@ -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,
];
+12
View File
@@ -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)';
+7
View File
@@ -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;
+10
View File
@@ -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();
};
+359
View File
@@ -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 });
}
}
}
+571
View File
@@ -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;
}
}
}
+164
View File
@@ -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);
}
};
+87
View File
@@ -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;
+239
View File
@@ -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;
+144
View File
@@ -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}
/>
);
};
+389
View File
@@ -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>&nbsp;</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);
+27
View File
@@ -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
+144
View File
@@ -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;
+15
View File
@@ -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