From c64bcae549897ab4324ddf2c750e0814509c5c80 Mon Sep 17 00:00:00 2001 From: Enksodsoon <229880765+Enksodsoon@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:17:36 +0700 Subject: [PATCH 01/85] Improve Forge import and Viewer sync UX --- frontend/src/App.tsx | 471 ++++++++++++++---- frontend/src/api.ts | 28 +- frontend/src/styles.css | 57 ++- frontend/src/test/App.test.tsx | 194 +++++++- frontend/src/test/api.test.ts | 19 + .../org/pathlab/forge/server/ForgeServer.java | 116 +++++ .../forge/viewer/SqliteViewerSyncStore.java | 30 ++ .../forge/viewer/ViewerSyncService.java | 12 +- .../pathlab/forge/viewer/ViewerSyncStore.java | 1 + .../pathlab/forge/server/ForgeServerTest.java | 2 +- .../viewer/SqliteViewerSyncStoreTest.java | 19 + .../forge/viewer/ViewerSyncServiceTest.java | 22 + 12 files changed, 850 insertions(+), 121 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f3cbb9..4ca6249 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,25 +5,38 @@ import { import { ArrowsOut, ArrowsClockwise, + ArrowUp, CaretDown, + CaretRight, CheckCircle, + CloudArrowDown, CloudArrowUp, Crosshair, + Database, + FileText, + Globe, Folder, FolderOpen, + HardDrive, House, + Info, Key, + LockKey, List, MagnifyingGlassMinus, MagnifyingGlassPlus, Moon, + PencilSimple, Plus, + Ruler, SidebarSimple, SignOut, Sun, + Tag, Trash, UploadSimple, Wrench, + X, } from '@phosphor-icons/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent, ReactNode, Ref } from 'react' @@ -49,6 +62,20 @@ const CANCELLABLE_STATUSES = new Set(['QUEUED', 'WAITING_RESOURCES', ...CONVERSI const QUEUEABLE_STATUSES = new Set(['READY', 'READY_TO_CONVERT', 'CONVERSION_READY', 'FAILED', 'CANCELLED']) const NO_ANNOTATIONS: AnnotationRecord[] = [] const DEFAULT_LOCAL_FOLDER = 'Unfiled' +const isCompactWorkspace = () => typeof window !== 'undefined' && window.innerWidth <= 1180 + +function viewerVisibility(slide?: Pick) { + if (slide?.visibility) return slide.visibility === 'published' ? 'Published' : 'Private' + const normalized = (slide?.state || '').trim().toLowerCase().replace(/[\s-]+/g, '_') + return ['published', 'public', 'shared'].includes(normalized) ? 'Published' : 'Private' +} + +function metadataText(value: unknown): string { + if (value === undefined || value === null || value === '') return '' + if (Array.isArray(value)) return value.map((entry) => metadataText(entry)).filter(Boolean).join(', ') + if (typeof value === 'object') return Object.values(value as Record).map((entry) => metadataText(entry)).filter(Boolean).join(', ') + return String(value) +} export function App() { const [datasets, setDatasets] = useState([]) @@ -77,6 +104,7 @@ export function App() { const [importOpen, setImportOpen] = useState(false) const [importing, setImporting] = useState(false) const [importPath, setImportPath] = useState('') + const [importError, setImportError] = useState('') const [removeTarget, setRemoveTarget] = useState() const [pairingOpen, setPairingOpen] = useState(false) const [viewerUrl, setViewerUrl] = useState('http://127.0.0.1:5173') @@ -85,6 +113,7 @@ export function App() { const [viewerUpload, setViewerUpload] = useState() const [remoteLibrary, setRemoteLibrary] = useState({ items: [], folders: [], conflicts: [] }) const [remoteSyncReady, setRemoteSyncReady] = useState(false) + const [remoteLastChecked, setRemoteLastChecked] = useState() const [selectedRemoteId, setSelectedRemoteId] = useState('') const [remoteRename, setRemoteRename] = useState<{ id: string; current: string; value: string }>() const [annotationsByDataset, setAnnotationsByDataset] = useState>({}) @@ -162,6 +191,13 @@ export function App() { window.localStorage.setItem('pathlab-forge-folder-map-v1', JSON.stringify(folderByDataset)) }, [folderByDataset]) + useEffect(() => { + const inspector = document.querySelector('.pathlab-viewer-inspector') + if (!inspector) return + inspector.inert = !inspectorOpen + inspector.setAttribute('aria-hidden', inspectorOpen ? 'false' : 'true') + }, [inspectorOpen]) + const refresh = useCallback(async () => { try { const next = await api.datasets() @@ -243,13 +279,19 @@ export function App() { } } - const handleNativeImport = async () => { + const handleSelectedImport = async (paths: string[]) => { + if (!paths.length) return + setImportError('') setImporting(true) try { - const next = await api.chooseDatasets() + let next: { datasets: Dataset[] } | undefined + for (const selectedPath of paths) next = await api.importDataset(selectedPath) + if (!next) return finishImport(next) } catch (nextError) { - setError(message(nextError)) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } @@ -257,20 +299,22 @@ export function App() { const handlePathImport = async () => { if (!importPath.trim()) return - setImportOpen(false) + setImportError('') setImporting(true) try { const next = await api.importDataset(importPath.trim()) finishImport(next) } catch (nextError) { - setError(message(nextError)) - setImportOpen(true) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } } const handleProjectImport = async (path?: string) => { + setImportError('') setImporting(true) try { const next = await api.importProjectFolder(path) @@ -279,7 +323,9 @@ export function App() { ? `${next.project.imported} slides imported from project folder${next.project.failed ? ` · ${next.project.failed}` : ''}` : 'Project folder selection closed') } catch (nextError) { - setError(message(nextError)) + const detail = message(nextError) + setImportError(detail) + setError(detail) } finally { setImporting(false) } @@ -373,15 +419,19 @@ export function App() { setNotice('Selected slides were removed from the Forge library; originals and completed exports were preserved') } - const syncViewer = async () => { - setLibraryMode('viewer') - setNavigatorOpen(true) + const syncViewer = async (revealLibrary = true) => { + if (revealLibrary) { + setLibraryMode('viewer') + setNavigatorOpen(true) + if (isCompactWorkspace()) setInspectorOpen(false) + } try { const next = await api.getViewerConnection() setConnection(next) if (next.connected) { setRemoteLibrary(await api.syncViewerLibrary()) setRemoteSyncReady(true) + setRemoteLastChecked(Date.now()) } else { setRemoteSyncReady(false) } @@ -401,6 +451,7 @@ export function App() { void api.syncViewerLibrary().then((next) => { setRemoteLibrary(next) setRemoteSyncReady(true) + setRemoteLastChecked(Date.now()) }).catch(() => setRemoteSyncReady(false)) }, 5_000) return () => window.clearInterval(timer) @@ -644,9 +695,10 @@ export function App() { onLocalLibrary={() => { setLibraryMode('local') setNavigatorOpen(true) + if (isCompactWorkspace()) setInspectorOpen(false) }} onViewerLibrary={() => void syncViewer()} - onImport={() => setImportOpen(true)} + onImport={() => { setImportError(''); setImportOpen(true) }} onFeatures={openFeatures} onTheme={toggleTheme} onSecurity={connect} @@ -676,12 +728,16 @@ export function App() { connection={connection} remoteLibrary={remoteLibrary} remoteSyncReady={remoteSyncReady} + remoteLastChecked={remoteLastChecked} checkedIds={selectedDatasetIds} folders={localFolders} folderByDataset={folderByDataset} onSelect={(id) => { setSelectedId(id) - if (window.innerWidth <= 960) setNavigatorOpen(false) + if (isCompactWorkspace()) { + setNavigatorOpen(false) + setInspectorOpen(true) + } }} onChecked={setSelectedDatasetIds} onCreateFolder={(name) => setLocalFolders((current) => current.includes(name) ? current : [...current, name])} @@ -691,9 +747,9 @@ export function App() { }))} onQueue={(ids) => void queueReadySlides(ids)} onRemove={(ids) => setBatchRemoveIds(ids)} - onImport={() => setImportOpen(true)} + onImport={() => { setImportError(''); setImportOpen(true) }} onConnect={connect} - onSync={() => void syncViewer()} + onSync={() => void syncViewer(false)} onKeepOffline={(id) => void api.keepViewerSlideOffline(id).then(() => { setNotice('Offline download started; verified activation will happen in the background') }).catch((nextError) => setError(message(nextError)))} @@ -702,7 +758,9 @@ export function App() { .catch((nextError) => setError(message(nextError)))} onRenameRemote={(id, current) => setRemoteRename({ id, current, value: current })} selectedRemoteId={selectedRemote?.id || ''} - onSelectRemote={setSelectedRemoteId} + onSelectRemote={(id) => { + setSelectedRemoteId(id) + }} onResolveConflict={(id, field, resolution) => void api.resolveViewerConflict(id, field, resolution) .then(() => api.syncViewerLibrary()).then(setRemoteLibrary) .catch((nextError) => setError(message(nextError)))} @@ -794,6 +852,7 @@ export function App() { isError={Boolean(error)} onClearError={() => setError('')} onQueueReady={() => void queueReadySlides()} + onOpen={() => setInspectorOpen(false)} /> )} /> @@ -823,12 +882,14 @@ export function App() { {importOpen ? ( void handleNativeImport()} - onChooseFolder={() => void handleProjectImport()} + busy={importing} + error={importError} + onPath={(value) => { setImportPath(value); setImportError('') }} + onImportSelected={(paths) => void handleSelectedImport(paths)} + onImportSelectedFolder={(path) => void handleProjectImport(path)} onImport={() => void handlePathImport()} onImportFolder={() => void handleProjectImport(importPath)} - onClose={() => setImportOpen(false)} + onClose={() => { if (!importing) { setImportError(''); setImportOpen(false) } }} /> ) : null} {removeTarget ? ( @@ -970,11 +1031,11 @@ function ForgeProductRail({