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>
365 lines
16 KiB
TypeScript
365 lines
16 KiB
TypeScript
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;
|
||
|
||
|