import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import { useAuth } from '../../../contexts/AuthContext'; type PageImagesManifest = { version: number; file_id: string; page_count: number; bucket?: string; base_dir?: string; page_images: Array<{ page: number; full_image_path: string; thumbnail_path: string; full_dimensions?: { width: number; height: number }; thumbnail_dimensions?: { width: number; height: number }; }> }; type OutlineSection = { id: string; title: string; level: number; start_page: number; end_page: number; parent_id?: string | null; children?: string[]; }; type Outline = { sections: OutlineSection[]; }; type QueueTaskBrief = { id: string; service?: string; task_type?: string; status?: string; priority?: string; created_at?: number; scheduled_at?: number; depends_on?: string[]; }; type FileTasksResponse = { file_id: string; count: number; tasks: QueueTaskBrief[] } | { error: string }; export const CCFileDetailPanel: React.FC<{ fileId: string; selectedPage: number; onSelectPage: (p: number) => void; }> = ({ fileId, selectedPage, onSelectPage }) => { const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [manifest, setManifest] = useState(null); const [outline, setOutline] = useState(null); const [collapsed, setCollapsed] = useState>(() => new Set()); // outline only used for grouping thumbnails const [thumbUrls] = useState>(() => new Map()); const [showAdmin, setShowAdmin] = useState(false); const [adminData, setAdminData] = useState(null); const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []); useEffect(() => { const run = async () => { if (!fileId) return; setLoading(true); setError(null); try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!mRes.ok) throw new Error(await mRes.text()); const m: PageImagesManifest = await mRes.json(); setManifest(m); // Try to load outline structure artefact (for grouping only) try { const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (artsRes.ok) { const arts: Array<{ id: string; type: string }> = await artsRes.json(); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); if (outlineArt) { const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (jsonRes.ok) { const outJson = await jsonRes.json(); const secs = (outJson.sections || []) as OutlineSection[]; setOutline({ sections: secs }); } } } } catch { // ignore } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load manifest'); } finally { setLoading(false); } }; run(); }, [fileId, API_BASE]); const fetchThumb = useCallback(async (page: number): Promise => { if (!manifest) return undefined; const cached = thumbUrls.get(page); if (cached) return cached; const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1)); const pg = manifest.page_images[idx]; if (!pg) return undefined; const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`; const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) return undefined; const blob = await resp.blob(); const objUrl = URL.createObjectURL(blob); thumbUrls.set(page, objUrl); return objUrl; }, [manifest, API_BASE, thumbUrls]); useEffect(() => { if (!manifest) return; // Prefetch first few thumbs const prefetch = async () => { const limit = Math.min(10, manifest.page_count || 0); for (let p = 1; p <= limit; p++) { // eslint-disable-next-line no-await-in-loop await fetchThumb(p); } }; prefetch(); }, [manifest, fetchThumb]); if (loading) return ; if (error) return {error}; if (!manifest) return No page images manifest.; return ( File Details { try { setShowAdmin(true); const token = accessToken || ''; const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); setAdminData(data); } catch (e) { setAdminData({ error: (e as Error)?.message || 'Failed to load' }); } }} title="Queue debug (admin)"> onSelectPage(Math.max(1, selectedPage - 1))}> onSelectPage(Number(e.target.value) || 1)} sx={{ flexShrink: 0 }} InputProps={{ sx: { width: 64, '& input': { textAlign: 'center', padding: '6px' } } }} inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', maxLength: 4 }} /> onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}> / {manifest.page_count} {outline && outline.sections.length > 0 && ( setCollapsed(new Set())}> setCollapsed(new Set(collectAllIds(outline.sections)))}> )} {showAdmin && ( Queue tasks for this file
{JSON.stringify(adminData, null, 2)}
)} {outline && outline.sections.length > 0 && ( Sections setCollapsed(new Set())}> setCollapsed(new Set(collectAllIds(outline.sections)))}> )} {renderGroupedTiles(manifest, outline, fetchThumb, selectedPage, onSelectPage, collapsed, (id) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }))}
); }; type SectionNode = { sec: OutlineSection; children: SectionNode[] }; const buildSectionTree = (sections: OutlineSection[]): SectionNode[] => { const roots: SectionNode[] = []; const stack: SectionNode[] = []; const sorted = [...sections].sort((a, b) => a.start_page - b.start_page); for (const s of sorted) { const level = Math.max(1, Number(s.level || 1)); const node: SectionNode = { sec: s, children: [] }; while (stack.length && (stack.length >= level)) stack.pop(); const parent = stack[stack.length - 1]; if (parent) parent.children.push(node); else roots.push(node); stack.push(node); } return roots; }; const renderGroupedTiles = ( manifest: PageImagesManifest, outline: Outline | null, fetchThumb: (p: number) => Promise, selectedPage: number, onSelectPage: (p: number) => void, collapsed: Set, toggleCollapse: (id: string) => void ) => { if (!outline || outline.sections.length === 0) { // No outline: show a simple grid of page tiles return ( {manifest.page_images.map((pg) => ( fetchThumb(pg.page)} selected={pg.page === selectedPage} onClick={() => onSelectPage(pg.page)} /> ))} ); } const tree = buildSectionTree(outline.sections); return tree.map((node) => ( )); }; // OutlineTree UI has been moved to top navigation; grouping-by-section thumbnails remain below. const SectionTile: React.FC<{ node: SectionNode; manifest: PageImagesManifest; fetchThumb: (p: number) => Promise; selectedPage: number; onSelectPage: (p: number) => void; collapsed: Set; toggleCollapse: (id: string) => void; level: number; }> = ({ node, manifest, fetchThumb, selectedPage, onSelectPage, collapsed, toggleCollapse, level }) => { const s = node.sec; const ml = (level - 1) * 1; const isCollapsed = collapsed.has(s.id); return ( { e.stopPropagation(); toggleCollapse(s.id); }}> {isCollapsed ? : } onSelectPage(Math.max(1, s.start_page))}> {s.title} ({s.start_page}–{s.end_page}) {!isCollapsed && ( {Array.from({ length: Math.max(0, Math.min(s.end_page, manifest.page_count) - s.start_page + 1) }).map((_, i) => { const p = s.start_page + i; return ( fetchThumb(p)} selected={p === selectedPage} onClick={() => onSelectPage(p)} /> ); })} )} {!isCollapsed && node.children.length > 0 && ( {node.children.map((child) => ( ))} )} ); }; function getCurrentSectionStart(outline: Outline | null, selectedPage: number): number { if (!outline || outline.sections.length === 0) return selectedPage; const secs = [...outline.sections].sort((a, b) => a.start_page - b.start_page); for (let i = 0; i < secs.length; i++) { const s = secs[i]; const end = s.end_page ?? (i + 1 < secs.length ? secs[i + 1].start_page - 1 : Number.MAX_SAFE_INTEGER); if (selectedPage >= s.start_page && selectedPage <= end) return s.start_page; } return selectedPage; } function collectAllIds(sections: OutlineSection[]): string[] { const ids: string[] = []; for (const s of sections) ids.push(s.id); return ids; } const PageTile: React.FC<{ page: number; selected: boolean; fetchSrc: () => Promise; onClick: () => void; }> = ({ page, selected, fetchSrc, onClick }) => { const [src, setSrc] = useState(undefined); useEffect(() => { (async () => setSrc(await fetchSrc()))(); }, [fetchSrc, page]); return ( {src ? {`p${page}`} : } {page} ); }; // Replaced old ThumbRow with PageTile-based grid tiles export default CCFileDetailPanel;