app/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx
kcar 83adcce951 feat(phase-b): school/timetable wizards, graph nav panel, UI updates
New components:
- CCGraphNavPanel: Supabase-driven navigation tree (school/timetable/calendar/classes sections),
  role-aware setup buttons, lazy child loading, academic/generic calendar toggle
- SchoolCalendarWizard: 3-step admin-only school setup (details → term dates → daily periods)
- TeacherTimetableWizard: period grid with existing slot pre-loading, edit-mode title

Updated:
- CCNodeSnapshotPanel: saves via Supabase storage path + accessToken
- BasePanel: nav panel tab wired to CCGraphNavPanel
- CCFilesPanelEnhanced: auth context fixes
- CCDocumentIntelligence suite: accessToken threading, Supabase storage integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:25:29 +01:00

365 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 ${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<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 = 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 <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 = 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)"><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))}><ArrowBackIosNew 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))}><ArrowForwardIos 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())}><ExpandMore fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRight 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())}><ExpandMore fontSize="inherit" /></IconButton>
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRight 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 ? <ChevronRight fontSize="inherit" /> : <ExpandMore 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;