This commit is contained in:
2025-11-14 14:47:26 +00:00
parent 69ecf2c7c1
commit 3b4876793e
104 changed files with 231517 additions and 1029 deletions
@@ -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;