import React, { useCallback, useEffect, useMemo, useRef, useState, useContext, useReducer, } from 'react'; import { createRoot } from 'react-dom/client'; import axios from 'axios'; import { HashRouter, Navigate, Route, Routes, Outlet, useLocation, useNavigate, matchPath, } from 'react-router-dom'; import './styles.css'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; import useApiError from './hooks/useApiError'; import SkeuomorphicWorkspace from './skeuomorphic_ws'; import { DownloadIcon, EditIcon } from './ui/icons'; import { generateRandomTagColor, getTagColorStyle, HEX_COLOR_PATTERN } from './utils/colors'; import Sidebar from './sidebar/Sidebar'; import DocumentsTable, { FilterBar } from './documents/DocumentsTable'; const runtimeApiBase = typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL ? window.__PAPERCRATE_API_BASE_URL : ''; const DEFAULT_DEV_API = 'http://127.0.0.1:3000'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, ''); const api = axios.create({ baseURL: API_ROOT ? `${API_ROOT}/api` : '/api', withCredentials: true, }); const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || ''; if (STORED_TOKEN) { api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; } const initialAppState = { status: STORED_TOKEN ? 'authenticated' : 'logged-out', token: STORED_TOKEN, error: null, isRefreshing: false, }; const AppStateContext = React.createContext(null); const AppDispatchContext = React.createContext(null); const appStateReducer = (state, action) => { switch (action.type) { case 'LOGIN_REQUEST': return { ...state, status: 'authenticating', error: null }; case 'LOGIN_SUCCESS': return { ...state, status: 'authenticated', token: action.token, error: null }; case 'LOGIN_FAILURE': return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; case 'BOOTSTRAP_START': return { ...state, status: 'bootstrapping', error: null }; case 'BOOTSTRAP_SUCCESS': return { ...state, status: 'ready', error: null }; case 'BOOTSTRAP_FAILURE': return { ...state, status: 'authenticated', error: action.error || null }; case 'TOKEN_REFRESH_START': return { ...state, isRefreshing: true, error: null }; case 'TOKEN_REFRESH_SUCCESS': return { ...state, token: action.token, isRefreshing: false, status: state.status === 'logged-out' ? 'authenticated' : state.status, }; case 'TOKEN_REFRESH_FAILURE': return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false }; case 'LOGOUT': return { status: 'logged-out', token: '', error: null, isRefreshing: false }; case 'RESET_ERROR': return { ...state, error: null }; default: return state; } }; const AppStateProvider = ({ children }) => { const [state, dispatch] = useReducer(appStateReducer, initialAppState); useEffect(() => { const token = state.token || ''; if (token) { api.defaults.headers.common.Authorization = `Bearer ${token}`; window.localStorage.setItem('papercrate_token', token); } else { delete api.defaults.headers.common.Authorization; window.localStorage.removeItem('papercrate_token'); } }, [state.token]); const stateValue = useMemo(() => state, [state]); return ( {children} ); }; const useAppState = () => { const context = useContext(AppStateContext); if (!context) { throw new Error('useAppState must be used within an AppStateProvider.'); } return context; }; const useAppDispatch = () => { const context = useContext(AppDispatchContext); if (!context) { throw new Error('useAppDispatch must be used within an AppStateProvider.'); } return context; }; const DEFAULT_FOLDER_NAME = 'All Documents'; const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path); const hasFiles = (event) => Array.from(event.dataTransfer?.types || []).includes('Files'); const createRootNode = () => ({ id: 'root', name: DEFAULT_FOLDER_NAME, parentId: null, children: [], expanded: true, loaded: false, }); const StatusBanner = ({ status }) => { if (!status) return null; return
{status.message}
; }; const DropOverlay = ({ active, folderName }) => (
Drop files to upload to {folderName}
); const LoginView = ({ onSubmit, status }) => (

Papercrate

Authenticate to manage your documents.

); const computeStackAngle = (docId, index) => { if (index === 0) return 0; let hash = 0; const source = docId || `stack-${index}`; for (let i = 0; i < source.length; i += 1) { hash = (hash * 31 + source.charCodeAt(i)) % 997; } const magnitude = Math.max(3, (hash % 13) + 3); // 3..15 const sign = index % 2 === 0 ? 1 : -1; return magnitude * sign; }; const MAX_PREVIEW_STACK_ITEMS = 15; const PreviewStack = ({ items = [], maxItems = MAX_PREVIEW_STACK_ITEMS, emptyMessage = 'Preview unavailable', onItemActivate, onOpenPreview, activeItemId = null, }) => { if (!items.length) { return {emptyMessage}; } const limited = items.slice(0, maxItems); const hasMultiple = limited.length > 1; const preparedItems = useMemo( () => limited.map((entry, index) => ({ entry, angle: index === 0 ? 0 : computeStackAngle(entry.id, index), offset: 0, })), [limited], ); return (
{preparedItems.map(({ entry, angle, offset }, index) => { const transform = hasMultiple ? `translate(-50%, -50%) rotate(${angle}deg)` : 'translate(-50%, -50%)'; const isFront = index === 0; return (
{entry.alt { event.stopPropagation(); if (isFront && onOpenPreview) { onOpenPreview(entry.id); } else if (onItemActivate) { onItemActivate(entry.id); } }} onKeyDown={(event) => { if (!onItemActivate && !onOpenPreview) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); if (isFront && onOpenPreview) { onOpenPreview(entry.id); } else { onItemActivate?.(entry.id); } } }} />
); })}
); }; const DetailPanel = ({ selectedDocuments = [], tags = [], tagLookupById = new Map(), tagLookupByLabel = new Map(), onTagAdd, onTagRemove, onRegenerateThumbnails, previewEntry, onOpenPreview, onBulkTagAdd, onBulkTagRemove, onBulkMove, onBulkReanalyze, folderOptions = [], defaultMoveTarget = 'root', onPromoteSelection, activePreviewId = null, onUpdateTitle = async () => false, ensureAssetUrl = null, getDocumentAsset = () => null, }) => { const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; const [titleEditDocId, setTitleEditDocId] = useState(null); const [titleDraft, setTitleDraft] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); useEffect(() => { if (!singleDoc) { setTitleEditDocId(null); setTitleDraft(''); setTitleError(null); setTitleSaving(false); return; } if (titleEditDocId && titleEditDocId !== singleDoc.id) { setTitleEditDocId(null); setTitleDraft(''); setTitleError(null); setTitleSaving(false); } }, [singleDoc, titleEditDocId]); const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); setTitleDraft(singleDoc.title || singleDoc.original_name || ''); setTitleError(null); }, [singleDoc]); const cancelTitleEdit = useCallback(() => { setTitleEditDocId(null); setTitleDraft(''); setTitleError(null); setTitleSaving(false); }, []); const submitTitleEdit = useCallback( async (event) => { event.preventDefault(); if (!singleDoc) return; const trimmed = titleDraft.trim(); if (!trimmed) { setTitleError('Title cannot be empty.'); return; } setTitleSaving(true); try { const ok = await onUpdateTitle(singleDoc.id, trimmed); if (ok) { setTitleEditDocId(null); setTitleDraft(''); setTitleError(null); } else { setTitleError('Failed to update title.'); } } finally { setTitleSaving(false); } }, [singleDoc, titleDraft, onUpdateTitle], ); const handlePreviewActivate = useCallback( (docId) => { if (!docId) return; onPromoteSelection?.(docId); }, [onPromoteSelection], ); const makePreviewItem = useCallback( (doc) => { if (!doc) return null; const url = resolveDocumentAssetUrl(doc, 'preview', { ensureAssetUrl, getAsset: getDocumentAsset, }); if (!url) { return null; } const asset = getDocumentAsset(doc, 'preview'); const width = Number(asset?.metadata?.width) || 0; const height = Number(asset?.metadata?.height) || 0; const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape'; return { id: doc.id, url, orientation, alt: doc.title || doc.original_name || 'Document preview', }; }, [ensureAssetUrl, getDocumentAsset], ); const stackDocuments = useMemo(() => { if (!selectedDocuments.length) return []; const seen = new Set(); const ordered = []; for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) { const doc = selectedDocuments[index]; if (!doc?.id || seen.has(doc.id)) continue; seen.add(doc.id); ordered.push(doc); if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) { break; } } return ordered; }, [selectedDocuments]); const singlePreviewItems = useMemo(() => { if (!singleDoc) return []; const item = makePreviewItem(singleDoc); return item ? [item] : []; }, [singleDoc, makePreviewItem]); const stackPreviews = useMemo( () => stackDocuments .map((doc) => makePreviewItem(doc)) .filter(Boolean), [stackDocuments, makePreviewItem], ); useEffect(() => { if (!ensureAssetUrl) { return; } stackDocuments.forEach((doc) => { resolveDocumentAssetUrl(doc, 'preview', { ensureAssetUrl, getAsset: getDocumentAsset, }); }); }, [stackDocuments, ensureAssetUrl, getDocumentAsset]); const commonTags = useMemo(() => { if (selectedCount < 2) return []; const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label))); if (!tagSets.length) return []; const intersection = new Set(tagSets[0]); tagSets.slice(1).forEach((set) => { [...intersection].forEach((label) => { if (!set.has(label)) { intersection.delete(label); } }); }); return [...intersection]; }, [selectedDocuments, selectedCount]); const stackTotalSizeBytes = useMemo(() => { if (!stackPreviews.length) return 0; const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc])); return stackPreviews.reduce((sum, item) => { const source = byId.get(item.id); const bytes = source?.current_version?.size_bytes; return sum + (typeof bytes === 'number' ? bytes : 0); }, 0); }, [stackPreviews, selectedDocuments]); const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; } const displayName = singleDoc.title || singleDoc.original_name; const downloadHref = singleDoc.current_version?.download_path ? resolveApiPath(singleDoc.current_version.download_path) : null; const isEditingTitle = titleEditDocId === singleDoc.id; const sizeBytes = Number(singleDoc.current_version?.size_bytes) || 0; const issuedAt = singleDoc.issued_at ? new Date(singleDoc.issued_at).toLocaleString() : '—'; const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; const metadata = singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; return ( <>
{isEditingTitle ? (
{ setTitleDraft(event.target.value); if (titleError) { setTitleError(null); } }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelTitleEdit(); } }} aria-label="Document title" autoFocus />
) : ( <>

{displayName}

)}
{titleError ?
{titleError}
: null}
Uploaded:{' '} {singleDoc.uploaded_at ? new Date(singleDoc.uploaded_at).toLocaleString() : '—'}
Size:{' '} {sizeBytes ? `${(sizeBytes / 1024).toFixed(1)} KB` : '—'}
Type: {singleDoc.content_type || 'Unknown'}
Issued: {issuedAt}
Original filename:{' '} {singleDoc.original_name}
{ if (!downloadHref) { event.preventDefault(); } }} > Download
Tags
{tagsForDoc.length ? ( tagsForDoc.map((tag) => { const colorSource = tag?.color || tagLookupById.get(tag.id)?.color; const style = getTagColorStyle(colorSource); return ( {tag.label}{' '} ); }) ) : ( No tags yet. )}
{ event.preventDefault(); const input = event.currentTarget.elements.tag; const value = input.value.trim(); if (!value) return; onTagAdd(singleDoc, value, input); }} > {tags.map((tag) => (
{metadata && (
Metadata
{JSON.stringify(metadata, null, 2)}
)} ); }; const renderBulk = () => { const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const sizeLabel = stackTotalSizeBytes ? `${(stackTotalSizeBytes / 1024).toFixed(1)} KB` : '—'; return ( <>

{countLabel}

Total size (stack): {sizeLabel}
Common tags:{' '} {commonTags.length ? commonTags.join(', ') : 'None'}
{commonTags.length > 0 && (
Bulk tag operations
{ event.preventDefault(); const input = event.currentTarget.elements.tag; const value = input.value.trim(); if (!value) return; onBulkTagAdd?.({ label: value, input }); }} >
{ event.preventDefault(); const input = event.currentTarget.elements.tag; const value = input.value.trim(); if (!value) return; onBulkTagRemove?.({ label: value, input }); }} >
)} {tags.map((tag) => (
); }; return ( ); }; const PreviewWorkspace = ({ document, previewEntry, onClose, onRegenerateThumbnails, }) => { if (!document) { return null; } const title = document.title || document.original_name || 'Document'; const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; const downloadHref = document.current_version?.download_path ? resolveApiPath(document.current_version.download_path) : null; const sizeBytes = Number(document.current_version?.size_bytes) || 0; const metadata = document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null; return (

{title}

{document.content_type || mime} {sizeBytes ? ` · ${(sizeBytes / 1024 / 1024).toFixed(2)} MB` : ''}
{ if (!downloadHref) { event.preventDefault(); } }} > Download
{!previewEntry?.url ? (
Loading preview…
) : (