latest
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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 { supabase } from '../../../supabaseClient';
|
||||
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||
const [outline, setOutline] = useState<Outline | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
// outline only used for grouping thumbnails
|
||||
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [adminData, setAdminData] = useState<FileTasksResponse | null>(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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
||||
});
|
||||
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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
||||
});
|
||||
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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
||||
});
|
||||
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<string | undefined> => {
|
||||
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 = (await supabase.auth.getSession()).data.session?.access_token || '';
|
||||
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 <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
|
||||
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
|
||||
if (!manifest) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>No page images manifest.</Box>;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', fontWeight: 600, flexShrink: 0, bgcolor: 'var(--color-panel)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>File Details
|
||||
<IconButton size="small" onClick={async () => {
|
||||
try {
|
||||
setShowAdmin(true);
|
||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
||||
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)"><AdminPanelSettingsIcon fontSize="inherit" /></IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => onSelectPage(Math.max(1, selectedPage - 1))}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
|
||||
<TextField
|
||||
size="small"
|
||||
value={selectedPage}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
|
||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>/ {manifest.page_count}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
|
||||
<Select size="small" value={getCurrentSectionStart(outline, selectedPage)} onChange={(e) => onSelectPage(Number(e.target.value))} displayEmpty sx={{ width: '100%', flexShrink: 0, '& .MuiSelect-select': { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }}>
|
||||
{!outline || outline.sections.length === 0 ? (
|
||||
<MenuItem value={selectedPage} disabled>No outline</MenuItem>
|
||||
) : (
|
||||
outline.sections.sort((a, b) => a.start_page - b.start_page).map((sec) => (
|
||||
<MenuItem key={sec.id} value={sec.start_page}>{sec.title.length > 60 ? `${sec.title.slice(0,60)}…` : sec.title} (p{sec.start_page})</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
{outline && outline.sections.length > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
|
||||
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'block', p: 1 }}>
|
||||
{showAdmin && (
|
||||
<Box sx={{ mb: 1, p: 1, border: '1px dashed var(--color-divider)', borderRadius: 1, bgcolor: 'var(--color-panel)' }}>
|
||||
<Typography variant="caption" sx={{ color: 'var(--color-text-3)' }}>Queue tasks for this file</Typography>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 11, margin: 0 }}>{JSON.stringify(adminData, null, 2)}</pre>
|
||||
</Box>
|
||||
)}
|
||||
{outline && outline.sections.length > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Sections</Typography>
|
||||
<Box>
|
||||
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
|
||||
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{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;
|
||||
}))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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<string | undefined>,
|
||||
selectedPage: number,
|
||||
onSelectPage: (p: number) => void,
|
||||
collapsed: Set<string>,
|
||||
toggleCollapse: (id: string) => void
|
||||
) => {
|
||||
if (!outline || outline.sections.length === 0) {
|
||||
// No outline: show a simple grid of page tiles
|
||||
return (
|
||||
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
|
||||
{manifest.page_images.map((pg) => (
|
||||
<PageTile key={pg.page} page={pg.page} fetchSrc={() => fetchThumb(pg.page)} selected={pg.page === selectedPage} onClick={() => onSelectPage(pg.page)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const tree = buildSectionTree(outline.sections);
|
||||
return tree.map((node) => (
|
||||
<SectionTile
|
||||
key={node.sec.id}
|
||||
node={node}
|
||||
manifest={manifest}
|
||||
fetchThumb={fetchThumb}
|
||||
selectedPage={selectedPage}
|
||||
onSelectPage={onSelectPage}
|
||||
collapsed={collapsed}
|
||||
toggleCollapse={toggleCollapse}
|
||||
level={Math.max(1, Number(node.sec.level || 1))}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
// 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<string | undefined>;
|
||||
selectedPage: number;
|
||||
onSelectPage: (p: number) => void;
|
||||
collapsed: Set<string>;
|
||||
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 (
|
||||
<Box sx={{ width: '100%', mt: 1, ml, border: '1px solid var(--color-divider)', borderRadius: 1, overflow: 'hidden', bgcolor: 'var(--color-panel)' }}>
|
||||
<Box sx={{ px: 1, py: 0.75, fontWeight: 700, color: 'var(--color-text-1)', display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', background:
|
||||
level === 1 ? 'rgba(0,0,0,0.03)' : level === 2 ? 'rgba(0,0,0,0.02)' : 'transparent',
|
||||
borderBottom: '1px solid var(--color-divider)'
|
||||
}}>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); toggleCollapse(s.id); }}>
|
||||
{isCollapsed ? <ChevronRightIcon fontSize="inherit" /> : <ExpandMoreIcon fontSize="inherit" />}
|
||||
</IconButton>
|
||||
<Box onClick={() => onSelectPage(Math.max(1, s.start_page))}>
|
||||
<Typography component="span" sx={{ color: 'var(--color-text-1)' }}>{s.title}</Typography>
|
||||
</Box>
|
||||
<Typography component="span" sx={{ fontSize: 12, color: 'var(--color-text-3)', ml: 1 }}>({s.start_page}–{s.end_page})</Typography>
|
||||
</Box>
|
||||
{!isCollapsed && (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1 }}>
|
||||
{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 (
|
||||
<PageTile key={`p-${p}`} page={p} fetchSrc={() => fetchThumb(p)} selected={p === selectedPage} onClick={() => onSelectPage(p)} />
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!isCollapsed && node.children.length > 0 && (
|
||||
<Box sx={{ p: 1 }}>
|
||||
{node.children.map((child) => (
|
||||
<SectionTile key={child.sec.id} node={child} manifest={manifest} fetchThumb={fetchThumb} selectedPage={selectedPage} onSelectPage={onSelectPage} collapsed={collapsed} toggleCollapse={toggleCollapse} level={Math.max(1, Number(child.sec.level || level + 1))} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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<string | undefined>;
|
||||
onClick: () => void;
|
||||
}> = ({ page, selected, fetchSrc, onClick }) => {
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
useEffect(() => { (async () => setSrc(await fetchSrc()))(); }, [fetchSrc, page]);
|
||||
return (
|
||||
<Box onClick={onClick} sx={{ width: '100%', minWidth: 0, display: 'flex', flexDirection: 'column', borderRadius: 1, overflow: 'hidden', cursor: 'pointer', border: selected ? '2px solid #1976d2' : '1px solid var(--color-divider)', boxShadow: selected ? '0 0 0 2px rgba(25,118,210,0.15) inset' : 'none', bgcolor: 'rgba(0,0,0,0.02)' }}>
|
||||
<Box sx={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(0,0,0,0.05)' }}>
|
||||
{src ? <img src={src} alt={`p${page}`} style={{ maxHeight: '100%', maxWidth: '100%', display: 'block' }} /> : <CircularProgress size={16} />}
|
||||
<Box sx={{ position: 'absolute', top: 6, right: 6, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', borderRadius: 1, px: 0.5, fontSize: 11, lineHeight: '16px' }}>{page}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Replaced old ThumbRow with PageTile-based grid tiles
|
||||
|
||||
export default CCFileDetailPanel;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user