From 4fd336c26c049472004df1a866151832fac600c8 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:33:49 +0000 Subject: [PATCH 1/2] Feature/v2/bookmarks (#4949) bookmark panel on viewer --- frontend/package-lock.json | 17 + frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 1 + .../components/viewer/BookmarkAPIBridge.tsx | 73 +++ .../components/viewer/BookmarkSidebar.css | 196 +++++++ .../components/viewer/BookmarkSidebar.tsx | 551 ++++++++++++++++++ .../core/components/viewer/EmbedPdfViewer.tsx | 63 +- .../core/components/viewer/LocalEmbedPDF.tsx | 6 + .../viewer/useViewerRightRailButtons.tsx | 14 +- frontend/src/core/contexts/ViewerContext.tsx | 30 + .../src/core/contexts/viewer/viewerActions.ts | 25 + .../src/core/contexts/viewer/viewerBridges.ts | 16 + 12 files changed, 990 insertions(+), 3 deletions(-) create mode 100644 frontend/src/core/components/viewer/BookmarkAPIBridge.tsx create mode 100644 frontend/src/core/components/viewer/BookmarkSidebar.css create mode 100644 frontend/src/core/components/viewer/BookmarkSidebar.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86f3f9a363..296fa51a21 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", @@ -633,6 +634,22 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-bookmark": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz", + "integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.4.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.4.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-export": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dbfd9d3479..ba96f1d04d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-bookmark": "^1.4.1", "@embedpdf/plugin-export": "^1.4.1", "@embedpdf/plugin-history": "^1.4.1", "@embedpdf/plugin-interaction-manager": "^1.4.1", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 7ff36357c7..dcaaed9742 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3825,6 +3825,7 @@ "downloadSelected": "Download Selected Files", "downloadAll": "Download All", "toggleTheme": "Toggle Theme", + "toggleBookmarks": "Toggle Bookmarks", "language": "Language", "search": "Search PDF", "panMode": "Pan Mode", diff --git a/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx new file mode 100644 index 0000000000..019b809202 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkAPIBridge.tsx @@ -0,0 +1,73 @@ +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react'; +import { BookmarkCapability } from '@embedpdf/plugin-bookmark'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges'; + +export function BookmarkAPIBridge() { + const { provides: bookmarkCapability } = useBookmarkCapability(); + const { registerBridge } = useViewer(); + const [state, setState] = useState({ + bookmarks: null, + isLoading: false, + error: null, + }); + + const fetchBookmarks = useCallback( + async (capability: BookmarkCapability) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + try { + const task = capability.getBookmarks(); + const result = await task.toPromise(); + setState({ + bookmarks: result.bookmarks ?? [], + isLoading: false, + error: null, + }); + return result.bookmarks ?? []; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + setState({ + bookmarks: null, + isLoading: false, + error: message, + }); + throw error; + } + }, + [] + ); + + const api = useMemo(() => { + if (!bookmarkCapability) return null; + + return { + fetchBookmarks: () => fetchBookmarks(bookmarkCapability), + clearBookmarks: () => { + setState({ + bookmarks: null, + isLoading: false, + error: null, + }); + }, + setLocalBookmarks: (bookmarks, error = null) => { + setState({ + bookmarks, + isLoading: false, + error, + }); + }, + }; + }, [bookmarkCapability, fetchBookmarks]); + + useEffect(() => { + if (!api) return; + + registerBridge('bookmark', { + state, + api, + }); + }, [api, state, registerBridge]); + + return null; +} diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.css b/frontend/src/core/components/viewer/BookmarkSidebar.css new file mode 100644 index 0000000000..da130a9570 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.css @@ -0,0 +1,196 @@ +/* Bookmark Sidebar - Modern styling to match AllTools section */ + +.bookmark-sidebar { + display: flex; + flex-direction: column; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-toolbar) 96%, transparent), + color-mix(in srgb, var(--bg-background) 90%, transparent) + ); + border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent); + box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent); + backdrop-filter: blur(12px); +} + +/* Header Section */ +.bookmark-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.875rem; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-subtle); +} + +.bookmark-sidebar__header-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.bookmark-sidebar__header-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-blue-6); + font-size: 1.1rem; +} + +/* Search Section */ +.bookmark-sidebar__search { + background: var(--tool-panel-search-bg, var(--bg-toolbar)); + border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle)); + padding-top: 0.75rem !important; +} + +/* Content Section */ +.bookmark-sidebar__content { + padding-top: 0.5rem !important; +} + +.bookmark-sidebar__empty-state, +.bookmark-sidebar__error { + padding: 2rem 1rem; + text-align: center; +} + +.bookmark-sidebar__loading { + padding: 2rem 1rem !important; +} + +/* Bookmark List */ +.bookmark-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Bookmark Items */ +.bookmark-item-wrapper { + display: flex; + flex-direction: column; +} + +.bookmark-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-radius: 0.65rem; + cursor: default; + transition: all 0.2s ease; + background: transparent; + border: 1px solid transparent; + width: 100%; + box-sizing: border-box; + position: relative; + text-align: left; + outline: none; +} + +.bookmark-item--clickable { + cursor: pointer; + background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); +} + +.bookmark-item--clickable:hover { + background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); + border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); + transform: translateX(2px); +} + +.bookmark-item--clickable:active { + transform: translateX(3px); +} + +.bookmark-item--clickable:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + outline-offset: 2px; +} + +/* Expand Icon */ +.bookmark-item__expand-icon { + flex-shrink: 0; + transition: transform 0.2s ease; + width: 2rem; + height: 2rem; +} + +.bookmark-item:hover .bookmark-item__expand-icon { + transform: scale(1.05); +} + +/* Dash for items without children */ +.bookmark-item__dash { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + color: var(--text-muted); + font-size: 1.25rem; + opacity: 0.5; + font-weight: 300; +} + +/* Content */ +.bookmark-item__content { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-width: 0; +} + +.bookmark-item__title { + line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--text-primary); +} + +.bookmark-item--clickable:hover .bookmark-item__title { + color: var(--text-primary); +} + +.bookmark-item__page { + line-height: 1.3; + font-size: 0.75rem; +} + +/* Children Container */ +.bookmark-item__children { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.25rem; +} + +/* Smooth expand/collapse animation */ +@keyframes bookmark-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bookmark-item__children { + animation: bookmark-fade-in 0.2s ease; +} + +/* Accessibility & Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .bookmark-item, + .bookmark-item__expand-icon, + .bookmark-item__children { + transition: none; + animation: none; + } +} + diff --git a/frontend/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/src/core/components/viewer/BookmarkSidebar.tsx new file mode 100644 index 0000000000..5c845878b8 --- /dev/null +++ b/frontend/src/core/components/viewer/BookmarkSidebar.tsx @@ -0,0 +1,551 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models'; +import BookmarksIcon from '@mui/icons-material/BookmarksRounded'; +import '@app/components/viewer/BookmarkSidebar.css'; + +interface BookmarkSidebarProps { + visible: boolean; + thumbnailVisible: boolean; + documentCacheKey?: string; + preloadCacheKeys?: string[]; +} + +const SIDEBAR_WIDTH = '15rem'; + +type BookmarkNode = PdfBookmarkObject & { id: string }; + +type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface BookmarkCacheEntry { + status: BookmarkCacheStatus; + bookmarks: PdfBookmarkObject[] | null; + error: string | null; + lastFetched: number | null; +} + +const createEntry = (overrides: Partial = {}): BookmarkCacheEntry => ({ + status: 'idle', + bookmarks: null, + error: null, + lastFetched: null, + ...overrides, +}); + +const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => { + const target = bookmark.target; + if (!target) return null; + + if (target.type === 'destination') { + return target.destination.pageIndex + 1; + } + + if (target.type === 'action') { + const action = target.action; + if ( + action.type === PdfActionType.Goto || + action.type === PdfActionType.RemoteGoto + ) { + return action.destination?.pageIndex !== undefined + ? action.destination.pageIndex + 1 + : null; + } + } + + return null; +}; + +export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => { + const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer(); + const [expanded, setExpanded] = useState>({}); + const [searchTerm, setSearchTerm] = useState(''); + const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport()); + const [activeEntry, setActiveEntry] = useState(() => createEntry()); + const cacheRef = useRef>(new Map()); + const [fetchNonce, setFetchNonce] = useState(0); + const currentKeyRef = useRef(documentCacheKey ?? null); + + useEffect(() => { + currentKeyRef.current = documentCacheKey ?? null; + }, [documentCacheKey]); + + // Poll once until the bookmark bridge registers + useEffect(() => { + if (bookmarkSupport) return; + let cancelled = false; + const id = setInterval(() => { + if (!cancelled && hasBookmarkSupport()) { + setBookmarkSupport(true); + clearInterval(id); + } + }, 250); + + return () => { + cancelled = true; + clearInterval(id); + }; + }, [bookmarkSupport, hasBookmarkSupport]); + + // Reset UI and load cached entry (if any) when switching documents + useEffect(() => { + setExpanded({}); + setSearchTerm(''); + + if (!documentCacheKey) { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + return; + } + + const cached = cacheRef.current.get(documentCacheKey); + if (cached) { + setActiveEntry(cached); + if (cached.status === 'success') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null); + } else if (cached.status === 'error') { + bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error); + } else { + bookmarkActions.clearBookmarks(); + } + } else { + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + } + }, [documentCacheKey, bookmarkActions]); + + // Keep cache bounded to the currently relevant keys + useEffect(() => { + const allowed = new Set(); + if (documentCacheKey) { + allowed.add(documentCacheKey); + } + preloadCacheKeys.forEach(key => { + if (key) { + allowed.add(key); + } + }); + + cacheRef.current.forEach((_entry, key) => { + if (!allowed.has(key)) { + cacheRef.current.delete(key); + } + }); + }, [documentCacheKey, preloadCacheKeys]); + + // Fetch bookmarks for the active document when needed + useEffect(() => { + if (!bookmarkSupport || !documentCacheKey) return; + + const key = documentCacheKey; + const cached = cacheRef.current.get(key); + if (cached && (cached.status === 'loading' || cached.status === 'success')) { + return; + } + + let cancelled = false; + const updateEntry = (entry: BookmarkCacheEntry) => { + cacheRef.current.set(key, entry); + if (!cancelled && currentKeyRef.current === key) { + setActiveEntry(entry); + } + }; + + updateEntry(createEntry({ + status: 'loading', + bookmarks: cached?.bookmarks ?? null, + lastFetched: cached?.lastFetched ?? null, + })); + + const fetchWithRetry = async () => { + const maxAttempts = 10; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const result = await bookmarkActions.fetchBookmarks(); + return Array.isArray(result) ? result : []; + } catch (error: any) { + const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; + const notReady = + message.includes('document') && + message.includes('not') && + message.includes('open'); + + if (!notReady || attempt === maxAttempts - 1) { + throw error; + } + + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + return []; + }; + + fetchWithRetry() + .then(bookmarks => { + if (cancelled) return; + const entry = createEntry({ + status: 'success', + bookmarks, + lastFetched: Date.now(), + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(bookmarks, null); + } + }) + .catch(error => { + if (cancelled) return; + const message = error instanceof Error ? error.message : 'Failed to load bookmarks'; + const fallback = cacheRef.current.get(key); + const entry = createEntry({ + status: 'error', + bookmarks: fallback?.bookmarks ?? null, + error: message, + lastFetched: fallback?.lastFetched ?? null, + }); + updateEntry(entry); + if (currentKeyRef.current === key) { + bookmarkActions.setLocalBookmarks(null, message); + } + }); + + return () => { + cancelled = true; + }; + }, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]); + + const requestReload = useCallback(() => { + if (!documentCacheKey) return; + cacheRef.current.delete(documentCacheKey); + setActiveEntry(createEntry()); + bookmarkActions.clearBookmarks(); + setFetchNonce(value => value + 1); + }, [documentCacheKey, bookmarkActions]); + + const bookmarksWithIds = useMemo(() => { + const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => { + if (!Array.isArray(nodes)) { + return []; + } + + return nodes.map((node, index) => { + const id = `${prefix}-${index}`; + return { + ...node, + id, + children: node.children ? assignIds(node.children, id) : undefined, + }; + }); + }; + + const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : []; + return assignIds(bookmarks); + }, [activeEntry.bookmarks]); + + const currentStatus = activeEntry.status; + const isLocalLoading = bookmarkSupport && currentStatus === 'loading'; + const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null; + + const toggleNode = (nodeId: string) => { + setExpanded(prev => ({ + ...prev, + [nodeId]: !(prev[nodeId] ?? true), + })); + }; + + const expandAll = useCallback(() => { + const allExpanded: Record = {}; + const expandRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allExpanded[node.id] = true; + expandRecursive(node.children as BookmarkNode[]); + } + }); + }; + expandRecursive(bookmarksWithIds); + setExpanded(allExpanded); + }, [bookmarksWithIds]); + + const collapseAll = useCallback(() => { + const allCollapsed: Record = {}; + const collapseRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach(node => { + if (node.children && node.children.length > 0) { + allCollapsed[node.id] = false; + collapseRecursive(node.children as BookmarkNode[]); + } + }); + }; + collapseRecursive(bookmarksWithIds); + setExpanded(allCollapsed); + }, [bookmarksWithIds]); + + const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => { + const target = bookmark.target; + if (target?.type === 'action') { + const action = target.action; + if (action.type === PdfActionType.URI && action.uri) { + event.preventDefault(); + window.open(action.uri, '_blank', 'noopener'); + return; + } + if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) { + event.preventDefault(); + window.open(action.path, '_blank', 'noopener'); + return; + } + } + + const pageNumber = resolvePageNumber(bookmark); + if (pageNumber) { + scrollActions.scrollToPage(pageNumber); + } + }; + + const filteredBookmarks = useMemo(() => { + if (!searchTerm.trim()) return bookmarksWithIds; + const term = searchTerm.trim().toLowerCase(); + + const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => { + const results: BookmarkNode[] = []; + + for (const node of nodeList) { + const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : []; + const matchesSelf = node.title?.toLowerCase().includes(term) ?? false; + + if (matchesSelf || childMatches.length > 0) { + results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children }); + } + } + + return results; + }; + + return applyFilter(bookmarksWithIds); + }, [bookmarksWithIds, searchTerm]); + + const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => { + if (!nodes || !Array.isArray(nodes)) { + return null; + } + + return nodes.map((node, _index) => { + if (!node || !node.id) { + return null; + } + + const hasChildren = Array.isArray(node.children) && node.children.length > 0; + const isNodeExpanded = expanded[node.id] ?? true; + + const pageNumber = resolvePageNumber(node); + + return ( +
0 ? `${depth * 0.75}rem` : '0', + }} + > +
handleBookmarkClick(node, event)} + role={pageNumber ? "button" : undefined} + tabIndex={pageNumber ? 0 : undefined} + onKeyDown={pageNumber ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleBookmarkClick(node, event as any); + } + } : undefined} + > + {hasChildren ? ( + { + event.stopPropagation(); + toggleNode(node.id); + }} + > + + + ) : ( + - + )} +
+ + {node.title || 'Untitled'} + + {pageNumber && ( + + Page {pageNumber} + + )} +
+
+ {hasChildren && isNodeExpanded && ( +
+ {renderBookmarks(node.children as BookmarkNode[], depth + 1)} +
+ )} +
+ ); + }); + }; + + const isSearchActive = searchTerm.trim().length > 0; + const hasBookmarks = bookmarksWithIds.length > 0; + const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0; + const showEmptyState = + bookmarkSupport && + documentCacheKey && + !isLocalLoading && + !currentError && + currentStatus === 'success' && + !hasBookmarks; + const showSearchEmpty = + bookmarkSupport && + documentCacheKey && + isSearchActive && + hasBookmarks && + filteredBookmarks.length === 0; + const showNoDocument = bookmarkSupport && !documentCacheKey; + + if (!visible) { + return null; + } + + return ( + +
+
+ + + + + Bookmarks + +
+ {bookmarkSupport && bookmarksWithIds.length > 0 && ( + <> + {Object.values(expanded).some(val => val === false) ? ( + + + + ) : ( + + + + )} + + )} +
+ + + setSearchTerm(event.currentTarget.value)} + leftSection={} + size="xs" + /> + + + + + {!bookmarkSupport && ( +
+ + Bookmark support is unavailable for this viewer. + +
+ )} + + {bookmarkSupport && showNoDocument && ( +
+ + Open a PDF to view its bookmarks. + +
+ )} + + {bookmarkSupport && documentCacheKey && currentError && ( + + + {currentError} + + + + )} + + {bookmarkSupport && documentCacheKey && isLocalLoading && ( + + + + Loading bookmarks... + + + )} + + {showEmptyState && ( +
+ + No bookmarks in this document + +
+ )} + + {showBookmarkList && ( +
+ {renderBookmarks(filteredBookmarks)} +
+ )} + + {showSearchEmpty && ( +
+ + No bookmarks match your search + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index bf9af4eca5..10386b2e8c 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF'; import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar'; import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar'; +import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar'; import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext'; import { useSignature } from '@app/contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; @@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({ const pdfContainerRef = useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { + isThumbnailSidebarVisible, + toggleThumbnailSidebar, + isBookmarkSidebarVisible, + zoomActions, + panActions: _panActions, + rotationActions: _rotationActions, + getScrollState, + getRotationState, + isAnnotationMode, + isAnnotationsVisible, + exportActions, + } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); @@ -128,6 +141,42 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); + const bookmarkCacheKey = React.useMemo(() => { + if (currentFile && isStirlingFile(currentFile)) { + return currentFile.fileId; + } + + if (previewFile) { + const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`; + return `preview-${uniquePreviewId}`; + } + + if (effectiveFile?.url) { + return effectiveFile.url; + } + + if (effectiveFile?.file instanceof File) { + const fileObj = effectiveFile.file; + return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`; + } + + return undefined; + }, [currentFile, effectiveFile, previewFile]); + + // Generate cache keys for all active files to enable preloading + const allBookmarkCacheKeys = React.useMemo(() => { + if (previewFile) { + return [bookmarkCacheKey].filter(Boolean) as string[]; + } + + return activeFiles.map(file => { + if (isStirlingFile(file)) { + return file.fileId; + } + return undefined; + }).filter(Boolean) as string[]; + }, [activeFiles, previewFile, bookmarkCacheKey]); + useWheelZoom({ ref: viewerRef, onZoomIn: zoomActions.zoomIn, @@ -219,6 +268,10 @@ const EmbedPdfViewerContent = ({ } }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); + const sidebarWidthRem = 15; + const totalRightMargin = + (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); + return ( + {/* Navigation Warning Modal */} {!previewFile && ( diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index f20b68b9a9..c08eabcbb7 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { ExportPluginPackage } from '@embedpdf/plugin-export/react'; +import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark'; // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; @@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; +import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register thumbnail plugin for page thumbnails createPluginRegistration(ThumbnailPluginPackage), + // Register bookmark plugin for PDF outline support + createPluginRegistration(BookmarkPluginPackage), + // Register rotate plugin createPluginRegistration(RotatePluginPackage), @@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur {enableAnnotations && } {enableAnnotations && } + (() => { return [ @@ -111,6 +112,17 @@ export function useViewerRightRailButtons() { viewer.toggleThumbnailSidebar(); } }, + { + id: 'viewer-toggle-bookmarks', + icon: , + tooltip: bookmarkLabel, + ariaLabel: bookmarkLabel, + section: 'top' as const, + order: 55, + onClick: () => { + viewer.toggleBookmarkSidebar(); + } + }, { id: 'viewer-annotation-controls', section: 'top' as const, @@ -120,7 +132,7 @@ export function useViewerRightRailButtons() { ) } ]; - }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]); + }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]); useRightRailButtons(viewerButtons); } diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index fc0f1ab70d..fc1eaf3464 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -17,6 +17,7 @@ import { RotationActions, SearchActions, ExportActions, + BookmarkActions, } from '@app/contexts/viewer/viewerActions'; import { BridgeRef, @@ -35,6 +36,7 @@ import { SearchState, ExportState, ThumbnailAPIWrapper, + BookmarkState, } from '@app/contexts/viewer/viewerBridges'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; @@ -74,6 +76,8 @@ interface ViewerContextType { // UI state managed by this context isThumbnailSidebarVisible: boolean; toggleThumbnailSidebar: () => void; + isBookmarkSidebarVisible: boolean; + toggleBookmarkSidebar: () => void; // Annotation visibility toggle isAnnotationsVisible: boolean; @@ -98,6 +102,8 @@ interface ViewerContextType { getSearchState: () => SearchState; getThumbnailAPI: () => ThumbnailAPIWrapper | null; getExportState: () => ExportState; + getBookmarkState: () => BookmarkState; + hasBookmarkSupport: () => boolean; // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void; @@ -118,6 +124,7 @@ interface ViewerContextType { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; // Bridge registration - internal use by bridges registerBridge: ( @@ -135,6 +142,7 @@ interface ViewerProviderProps { export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); + const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -193,6 +201,10 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsThumbnailSidebarVisible(prev => !prev); }; + const toggleBookmarkSidebar = () => { + setIsBookmarkSidebarVisible(prev => !prev); + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -242,6 +254,18 @@ export const ViewerProvider: React.FC = ({ children }) => { return bridgeRefs.current.export?.state || { canExport: false }; }; + const getBookmarkState = (): BookmarkState => { + return ( + bridgeRefs.current.bookmark?.state || { + bookmarks: null, + isLoading: false, + error: null, + } + ); + }; + + const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark); + // Action handlers - call APIs directly const { scrollActions, @@ -252,6 +276,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, } = createViewerActions({ registry: bridgeRefs, getScrollState, @@ -263,6 +288,8 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state isThumbnailSidebarVisible, toggleThumbnailSidebar, + isBookmarkSidebarVisible, + toggleBookmarkSidebar, // Annotation controls isAnnotationsVisible, @@ -285,6 +312,8 @@ export const ViewerProvider: React.FC = ({ children }) => { getSearchState, getThumbnailAPI, getExportState, + getBookmarkState, + hasBookmarkSupport, // Immediate updates registerImmediateZoomUpdate, @@ -303,6 +332,7 @@ export const ViewerProvider: React.FC = ({ children }) => { rotationActions, searchActions, exportActions, + bookmarkActions, // Bridge registration registerBridge, diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index daa9cb8c95..05a38440a5 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -5,6 +5,7 @@ import { ScrollState, ZoomState, } from '@app/contexts/viewer/viewerBridges'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollActions { scrollToPage: (page: number) => void; @@ -58,6 +59,12 @@ export interface ExportActions { saveAsCopy: () => Promise; } +export interface BookmarkActions { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ViewerActionsBundle { scrollActions: ScrollActions; zoomActions: ZoomActions; @@ -67,6 +74,7 @@ export interface ViewerActionsBundle { rotationActions: RotationActions; searchActions: SearchActions; exportActions: ExportActions; + bookmarkActions: BookmarkActions; } interface ViewerActionDependencies { @@ -307,5 +315,22 @@ export function createViewerActions({ rotationActions, searchActions, exportActions, + bookmarkActions: { + fetchBookmarks: async () => { + const api = registry.current.bookmark?.api; + if (!api?.fetchBookmarks) { + return null; + } + return api.fetchBookmarks(); + }, + clearBookmarks: () => { + const api = registry.current.bookmark?.api; + api?.clearBookmarks?.(); + }, + setLocalBookmarks: (bookmarks, error = null) => { + const api = registry.current.bookmark?.api; + api?.setLocalBookmarks?.(bookmarks ?? null, error); + }, + }, }; } diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts index 032a05e18d..e7f1ebf3c0 100644 --- a/frontend/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -1,4 +1,5 @@ import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import { PdfBookmarkObject } from '@embedpdf/models'; export interface ScrollAPIWrapper { scrollToPage: (params: { pageNumber: number }) => void; @@ -59,6 +60,12 @@ export interface ExportAPIWrapper { saveAsCopy: () => { toPromise: () => Promise }; } +export interface BookmarkAPIWrapper { + fetchBookmarks: () => Promise; + clearBookmarks: () => void; + setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void; +} + export interface ScrollState { currentPage: number; totalPages: number; @@ -103,6 +110,12 @@ export interface ExportState { canExport: boolean; } +export interface BookmarkState { + bookmarks: PdfBookmarkObject[] | null; + isLoading: boolean; + error: string | null; +} + export interface BridgeRef { state: TState; api: TApi; @@ -118,6 +131,7 @@ export interface BridgeStateMap { search: SearchState; thumbnail: unknown; export: ExportState; + bookmark: BookmarkState; } export interface BridgeApiMap { @@ -130,6 +144,7 @@ export interface BridgeApiMap { search: SearchAPIWrapper; thumbnail: ThumbnailAPIWrapper; export: ExportAPIWrapper; + bookmark: BookmarkAPIWrapper; } export type BridgeKey = keyof BridgeStateMap; @@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ search: null, thumbnail: null, export: null, + bookmark: null, }); export function registerBridge( From e1a879a5f69ba576c85df3bd05161b2518c0ffa2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 21 Nov 2025 13:19:53 +0000 Subject: [PATCH 2/2] Differentiate unavailable tools by reason (#4916) ## Summary - track endpoint disable reasons server-side and expose them through a new `/api/v1/config/endpoints-availability` API that the frontend can consume - refresh the web UI tool management logic to cache endpoint details, compute per-tool availability metadata, and show reason-specific messaging (admin disabled vs missing dependency) when a tool cannot be launched - add the missing en-GB translations for the new unavailability labels so the UI copy reflects the new distinction image image ## Testing - `npm run typecheck:core` *(fails: frontend/src/core/components/shared/LocalIcon.tsx expects ../../../assets/material-symbols-icons.json, which is not present in this environment)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6919af7a493c8328bb5ac3d07e65452b) --- .../SPDF/config/EndpointConfiguration.java | 89 +++++++- .../SPDF/config/ExternalAppDepConfig.java | 11 +- .../controller/api/misc/ConfigController.java | 20 ++ .../public/locales/en-GB/translation.json | 8 +- .../config/configSections/GeneralSection.tsx | 190 +++++++++++------- .../tools/convert/ConvertSettings.tsx | 42 +++- .../tools/fullscreen/CompactToolItem.tsx | 9 +- .../tools/fullscreen/DetailedToolItem.tsx | 14 +- .../components/tools/fullscreen/shared.ts | 53 ++++- .../tools/toolPicker/ToolButton.tsx | 12 +- .../src/core/contexts/ToolWorkflowContext.tsx | 14 +- .../core/data/useTranslatedToolRegistry.tsx | 14 +- .../useRemoveCertificateSignParameters.ts | 2 +- frontend/src/core/hooks/useEndpointConfig.ts | 121 +++++++---- frontend/src/core/hooks/useToolManagement.tsx | 78 ++++++- .../src/core/services/preferencesService.ts | 4 + frontend/src/core/services/updateService.ts | 4 +- .../src/core/types/endpointAvailability.ts | 6 + .../src/desktop/hooks/useEndpointConfig.ts | 33 ++- 19 files changed, 542 insertions(+), 182 deletions(-) create mode 100644 frontend/src/core/types/endpointAvailability.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 0178c25971..e4974e4a16 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties; @Slf4j public class EndpointConfiguration { + public enum DisableReason { + CONFIG, + DEPENDENCY, + UNKNOWN + } + + public static class EndpointAvailability { + private final boolean enabled; + private final DisableReason reason; + + public EndpointAvailability(boolean enabled, DisableReason reason) { + this.enabled = enabled; + this.reason = reason; + } + + public boolean isEnabled() { + return enabled; + } + + public DisableReason getReason() { + return reason; + } + } + private static final String REMOVE_BLANKS = "remove-blanks"; private final ApplicationProperties applicationProperties; @Getter private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); private Set disabledGroups = new HashSet<>(); + private Map endpointDisableReasons = new ConcurrentHashMap<>(); + private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; @@ -35,16 +61,31 @@ public class EndpointConfiguration { processEnvironmentConfigs(); } + private String normalizeEndpoint(String endpoint) { + if (endpoint == null) { + return null; + } + return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint; + } + public void enableEndpoint(String endpoint) { - endpointStatuses.put(endpoint, true); - log.debug("Enabled endpoint: {}", endpoint); + String normalized = normalizeEndpoint(endpoint); + endpointStatuses.put(normalized, true); + endpointDisableReasons.remove(normalized); + log.debug("Enabled endpoint: {}", normalized); } public void disableEndpoint(String endpoint) { - if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) { - log.debug("Disabling endpoint: {}", endpoint); + disableEndpoint(endpoint, DisableReason.CONFIG); + } + + public void disableEndpoint(String endpoint, DisableReason reason) { + String normalized = normalizeEndpoint(endpoint); + if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + log.debug("Disabling endpoint: {}", normalized); } - endpointStatuses.put(endpoint, false); + endpointStatuses.put(normalized, false); + endpointDisableReasons.put(normalized, reason); } public boolean isEndpointEnabled(String endpoint) { @@ -150,6 +191,10 @@ public class EndpointConfiguration { } public void disableGroup(String group) { + disableGroup(group, DisableReason.CONFIG); + } + + public void disableGroup(String group, DisableReason reason) { if (disabledGroups.add(group)) { if (isToolGroup(group)) { log.debug( @@ -161,11 +206,12 @@ public class EndpointConfiguration { group); } } + groupDisableReasons.put(group, reason); // Only cascade to endpoints for *functional* groups if (!isToolGroup(group)) { Set endpoints = endpointGroups.get(group); if (endpoints != null) { - endpoints.forEach(this::disableEndpoint); + endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason)); } } } @@ -174,12 +220,39 @@ public class EndpointConfiguration { if (disabledGroups.remove(group)) { log.debug("Enabling group: {}", group); } + groupDisableReasons.remove(group); Set endpoints = endpointGroups.get(group); if (endpoints != null) { endpoints.forEach(this::enableEndpoint); } } + public EndpointAvailability getEndpointAvailability(String endpoint) { + boolean enabled = isEndpointEnabled(endpoint); + DisableReason reason = enabled ? null : determineDisableReason(endpoint); + return new EndpointAvailability(enabled, reason); + } + + private DisableReason determineDisableReason(String endpoint) { + String normalized = normalizeEndpoint(endpoint); + if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) { + return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG); + } + + for (Map.Entry> entry : endpointGroups.entrySet()) { + String group = entry.getKey(); + Set endpoints = entry.getValue(); + if (!disabledGroups.contains(group) || endpoints == null) { + continue; + } + if (endpoints.contains(normalized)) { + return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG); + } + } + + return DisableReason.UNKNOWN; + } + public Set getDisabledGroups() { return new HashSet<>(disabledGroups); } @@ -261,6 +334,8 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "eml-to-pdf"); + addEndpointToGroup("Convert", "cbz-to-pdf"); + addEndpointToGroup("Convert", "pdf-to-cbz"); // Adding endpoints to "Security" group addEndpointToGroup("Security", "add-password"); @@ -394,6 +469,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "compress-pdf"); + addEndpointToGroup("Java", "cbz-to-pdf"); + addEndpointToGroup("Java", "pdf-to-cbz"); addEndpointToGroup("rar", "pdf-to-cbr"); // Javascript diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index fd3ab640d9..8c3a046f49 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.util.RegexPatternUtils; @@ -97,7 +98,7 @@ public class ExternalAppDepConfig { if (affectedGroups != null) { for (String group : affectedGroups) { List affectedFeatures = getAffectedFeatures(group); - endpointConfiguration.disableGroup(group); + endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY); log.warn( "Missing dependency: {} - Disabling group: {} (Affected features: {})", command, @@ -127,8 +128,8 @@ public class ExternalAppDepConfig { if (!pythonAvailable) { List pythonFeatures = getAffectedFeatures("Python"); List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("Python"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}", String.join(", ", pythonFeatures), @@ -146,14 +147,14 @@ public class ExternalAppDepConfig { int exitCode = process.waitFor(); if (exitCode != 0) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "OpenCV not available in Python - Disabling OpenCV features: {}", String.join(", ", openCVFeatures)); } } catch (Exception e) { List openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Error checking OpenCV: {} - Disabling OpenCV features: {}", e.getMessage(), diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index a4a169cd91..750bfab48f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -1,6 +1,7 @@ package stirling.software.SPDF.controller.api.misc; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.context.ApplicationContext; @@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; @@ -200,4 +205,19 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/endpoints-availability") + public ResponseEntity> getEndpointAvailability( + @RequestParam(name = "endpoints") + @Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints") + List<@NotBlank String> endpoints) { + Map result = new HashMap<>(); + for (String endpoint : endpoints) { + String trimmedEndpoint = endpoint.trim(); + result.put( + trimmedEndpoint, + endpointConfiguration.getEndpointAvailability(trimmedEndpoint)); + } + return ResponseEntity.ok(result); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index dcaaed9742..50e047698a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -17,6 +17,8 @@ "comingSoon": "Coming soon:", "favorite": "Add to favourites", "favorites": "Favourites", + "unavailable": "Disabled by server administrator:", + "unavailableDependency": "Unavailable - required tool missing on server:", "heading": "All tools (fullscreen view)", "noResults": "Try adjusting your search or toggle descriptions to find what you need.", "recommended": "Recommended", @@ -5721,7 +5723,11 @@ "latestVersion": "Latest Version", "checkForUpdates": "Check for Updates", "viewDetails": "View Details" - } + }, + "hideUnavailableTools": "Hide unavailable tools", + "hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.", + "hideUnavailableConversions": "Hide unavailable conversions", + "hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out." }, "hotkeys": { "errorConflict": "Shortcut already used by {{tool}}.", diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index e80a1fb07d..e8f37a2866 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -53,11 +53,17 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => }; const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); - if (summary) { + if (summary && summary.latest_version) { const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; if (isNewerVersion) { setUpdateSummary(summary); + } else { + // Clear any existing update summary if user is on latest version + setUpdateSummary(null); } + } else { + // No update available (latest_version is null) - clear any existing update summary + setUpdateSummary(null); } setCheckingUpdate(false); }; @@ -128,83 +134,6 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} - - -
-
- - {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} - - - {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} - -
- updatePreference('defaultToolPanelMode', val as ToolPanelMode)} - data={[ - { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, - { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, - ]} - /> -
- -
-
- - {t('settings.general.autoUnzip', 'Auto-unzip API responses')} - - - {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} - -
- updatePreference('autoUnzip', event.currentTarget.checked)} - /> -
-
- - -
-
- - {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} - - - {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} - -
- { - const numValue = Number(fileLimitInput); - const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; - setFileLimitInput(finalValue); - updatePreference('autoUnzipFileLimit', finalValue); - }} - min={1} - max={100} - step={1} - disabled={!preferences.autoUnzip} - style={{ width: 90 }} - /> -
-
-
-
- {/* Update Check Section */} {config?.appVersion && ( @@ -292,6 +221,111 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => )} + + +
+
+ + {t('settings.general.defaultToolPickerMode', 'Default tool picker mode')} + + + {t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')} + +
+ updatePreference('defaultToolPanelMode', val as ToolPanelMode)} + data={[ + { label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' }, + { label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' }, + ]} + /> +
+
+
+ + {t('settings.general.hideUnavailableTools', 'Hide unavailable tools')} + + + {t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableTools', event.currentTarget.checked)} + /> +
+
+
+ + {t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')} + + + {t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableConversions', event.currentTarget.checked)} + /> +
+ +
+
+ + {t('settings.general.autoUnzip', 'Auto-unzip API responses')} + + + {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} + +
+ updatePreference('autoUnzip', event.currentTarget.checked)} + /> +
+
+ + +
+
+ + {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} + + + {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} + +
+ { + const numValue = Number(fileLimitInput); + const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; + setFileLimitInput(finalValue); + updatePreference('autoUnzipFileLimit', finalValue); + }} + min={1} + max={100} + step={1} + disabled={!preferences.autoUnzip} + style={{ width: 90 }} + /> +
+
+
+
+ {/* Update Modal */} {updateSummary && config?.appVersion && config?.machineType && ( getConversionEndpoints(EXTENSION_TO_ENDPOINT), []); + const allEndpoints = useMemo(() => { + const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT); + return endpoints; + }, []); const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); @@ -56,7 +61,8 @@ const ConvertSettings = ({ const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; if (!endpointKey) return false; - return endpointStatus[endpointKey] === true; + const isAvailable = endpointStatus[endpointKey] === true; + return isAvailable; }; // Enhanced FROM options with endpoint availability @@ -74,6 +80,12 @@ const ConvertSettings = ({ }; }); + // Filter out unavailable source formats if preference is enabled + let filteredOptions = baseOptions; + if (preferences.hideUnavailableConversions) { + filteredOptions = baseOptions.filter(opt => opt.enabled !== false); + } + // Add dynamic format option if current selection is a file- format if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) { const extension = parameters.fromExtension.replace('file-', ''); @@ -85,22 +97,32 @@ const ConvertSettings = ({ }; // Add the dynamic option at the beginning - return [dynamicOption, ...baseOptions]; + return [dynamicOption, ...filteredOptions]; } - return baseOptions; - }, [parameters.fromExtension, endpointStatus]); + return filteredOptions; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); // Enhanced TO options with endpoint availability const enhancedToOptions = useMemo(() => { if (!parameters.fromExtension) return []; const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; - return availableOptions.map(option => ({ - ...option, - enabled: isConversionAvailable(parameters.fromExtension, option.value) - })); - }, [parameters.fromExtension, endpointStatus]); + const enhanced = availableOptions.map(option => { + const enabled = isConversionAvailable(parameters.fromExtension, option.value); + return { + ...option, + enabled + }; + }); + + // Filter out unavailable conversions if preference is enabled + if (preferences.hideUnavailableConversions) { + return enhanced.filter(opt => opt.enabled !== false); + } + + return enhanced; + }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]); const resetParametersToDefaults = () => { onParameterChange('imageOptions', { diff --git a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx index 648f79267e..4a9b30869c 100644 --- a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface CompactToolItemProps { id: string; @@ -17,7 +17,7 @@ interface CompactToolItemProps { const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, false); const iconClasses = 'tool-panel__fullscreen-list-icon'; @@ -73,9 +73,12 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, ); + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + const tooltipContent = disabled ? ( - {t('toolPanel.fullscreen.comingSoon', 'Coming soon:')} {tool.description} + {disabledMessage} {tool.description} ) : (
diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 2ebf93b357..8352a3c4e3 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; -import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared'; +import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared'; interface DetailedToolItemProps { id: string; @@ -15,7 +15,7 @@ interface DetailedToolItemProps { const DetailedToolItem: React.FC = ({ id, tool, isSelected, onClick }) => { const { t } = useTranslation(); - const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool); + const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool); const categoryColor = getSubcategoryColor(tool.subcategoryId); const iconBg = getIconBackground(categoryColor, true); @@ -34,6 +34,9 @@ const DetailedToolItem: React.FC = ({ id, tool, isSelecte iconNode = tool.icon; } + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + return (