- Fix icon naming: remove 'Icon' suffix from MUI icon components in Header.tsx (AccessTime, Close, Person, School, Schedule, Class, Book, Settings, Student, Login, Logout) - Update timetable components to use UserContext instead of ProfileContext - Fix timetableService naming collision and circular reference - Update various components for consistency
575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
|
import {
|
|
Box, CircularProgress, IconButton, Typography, Collapse, Chip,
|
|
List, ListItem, ListItemButton, ListItemIcon, ListItemText
|
|
} from '@mui/material';
|
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
|
import ChevronRight from '@mui/icons-material/ChevronRight';
|
|
import Description from '@mui/icons-material/Description';
|
|
import Check from '@mui/icons-material/Check';
|
|
import Schedule from '@mui/icons-material/Schedule';
|
|
import Visibility from '@mui/icons-material/Visibility';
|
|
import Psychology from '@mui/icons-material/Psychology';
|
|
import Overview from '@mui/icons-material/Home';
|
|
import { supabase } from '../../../supabaseClient';
|
|
|
|
// Types
|
|
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 ProcessingStatus = {
|
|
tika: boolean;
|
|
frontmatter: boolean;
|
|
structure_analysis: boolean;
|
|
split_map: boolean;
|
|
page_images: boolean;
|
|
docling_ocr: boolean;
|
|
docling_no_ocr: boolean;
|
|
docling_vlm: boolean;
|
|
};
|
|
|
|
type SectionNode = {
|
|
sec: OutlineSection;
|
|
children: SectionNode[];
|
|
};
|
|
|
|
interface CCEnhancedFilePanelProps {
|
|
fileId: string;
|
|
selectedPage: number;
|
|
onSelectPage: (page: number) => void;
|
|
currentSection?: { start: number; end: number };
|
|
}
|
|
|
|
export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|
fileId, selectedPage, onSelectPage, currentSection
|
|
}) => {
|
|
// State
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
|
const [outline, setOutline] = useState<OutlineSection[]>([]);
|
|
const [processingStatus, setProcessingStatus] = useState<ProcessingStatus>({
|
|
tika: false, frontmatter: false, structure_analysis: false, split_map: false,
|
|
page_images: false, docling_ocr: false, docling_no_ocr: false, docling_vlm: false
|
|
});
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
const [selectedView, setSelectedView] = useState<'overview' | 'structure' | 'thumbnails'>('structure');
|
|
const [thumbUrls] = useState<Map<number, string>>(() => new Map());
|
|
|
|
// Refs for scroll syncing
|
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
|
|
const API_BASE = useMemo(() =>
|
|
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
|
|
[]
|
|
);
|
|
|
|
// Load data
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
if (!fileId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
|
|
|
// Load page images manifest
|
|
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (manifestRes.ok) {
|
|
const m: PageImagesManifest = await manifestRes.json();
|
|
setManifest(m);
|
|
}
|
|
|
|
// Load artefacts to determine processing status and structure
|
|
const artefactsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (artefactsRes.ok) {
|
|
const artefacts: Array<{
|
|
id: string;
|
|
type: string;
|
|
status: string;
|
|
extra?: {
|
|
config?: {
|
|
do_ocr?: boolean;
|
|
};
|
|
};
|
|
}> = await artefactsRes.json();
|
|
|
|
// Determine processing status
|
|
const status: ProcessingStatus = {
|
|
tika: artefacts.some((a) => a.type === 'tika_json' && a.status === 'completed'),
|
|
frontmatter: artefacts.some((a) => a.type === 'docling_frontmatter_json' && a.status === 'completed'),
|
|
structure_analysis: artefacts.some((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed'),
|
|
split_map: artefacts.some((a) => a.type === 'split_map_json' && a.status === 'completed'),
|
|
page_images: artefacts.some((a) => a.type === 'page_images' && a.status === 'completed'),
|
|
docling_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === true) && a.status === 'completed'),
|
|
docling_no_ocr: artefacts.some((a) => a.type === 'docling_standard' && (a.extra?.config?.do_ocr === false) && a.status === 'completed'),
|
|
docling_vlm: artefacts.some((a) => a.type === 'docling_vlm' && a.status === 'completed')
|
|
};
|
|
setProcessingStatus(status);
|
|
|
|
// Load document outline/structure
|
|
const outlineArt = artefacts.find((a) => a.type === 'document_outline_hierarchy' && a.status === 'completed');
|
|
if (outlineArt) {
|
|
const structureRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (structureRes.ok) {
|
|
const structureData = await structureRes.json();
|
|
const sections = (structureData.sections || []) as OutlineSection[];
|
|
setOutline(sections);
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'Failed to load file data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}, [fileId, API_BASE]);
|
|
|
|
// Build hierarchical section tree
|
|
const sectionTree = useMemo(() => {
|
|
const buildTree = (sections: OutlineSection[]): SectionNode[] => {
|
|
const roots: SectionNode[] = [];
|
|
const stack: SectionNode[] = [];
|
|
const sorted = [...sections].sort((a, b) => a.start_page - b.start_page);
|
|
|
|
for (const sec of sorted) {
|
|
const level = Math.max(1, Number(sec.level || 1));
|
|
const node: SectionNode = { sec, children: [] };
|
|
|
|
// Maintain proper hierarchy based on level
|
|
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;
|
|
};
|
|
|
|
return buildTree(outline);
|
|
}, [outline]);
|
|
|
|
// Thumbnail fetching with lazy loading
|
|
const fetchThumbnail = useCallback(async (page: number): Promise<string | undefined> => {
|
|
if (!manifest) return undefined;
|
|
const cached = thumbUrls.get(page);
|
|
if (cached) return cached;
|
|
|
|
const pageIndex = Math.max(0, Math.min((manifest.page_count || 1) - 1, page - 1));
|
|
const pageInfo = manifest.page_images[pageIndex];
|
|
if (!pageInfo) return undefined;
|
|
|
|
try {
|
|
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
|
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
|
|
if (!response.ok) return undefined;
|
|
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
thumbUrls.set(page, objectUrl);
|
|
return objectUrl;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [manifest, API_BASE, thumbUrls]);
|
|
|
|
// Render overview panel
|
|
const renderOverview = () => (
|
|
<Box sx={{ p: 2 }}>
|
|
<Typography variant="h6" gutterBottom>Processing Status</Typography>
|
|
|
|
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 1: Structure Discovery</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
<StatusItem label="Tika Metadata" status={processingStatus.tika} />
|
|
<StatusItem label="Document Frontmatter" status={processingStatus.frontmatter} />
|
|
<StatusItem label="Structure Analysis" status={processingStatus.structure_analysis} />
|
|
<StatusItem label="Split Map" status={processingStatus.split_map} />
|
|
<StatusItem label="Page Images" status={processingStatus.page_images} />
|
|
</Box>
|
|
|
|
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: 600 }}>Phase 2: Content Processing</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
<StatusItem label="OCR Processing" status={processingStatus.docling_ocr} icon={<Visibility />} />
|
|
<StatusItem label="No-OCR Processing" status={processingStatus.docling_no_ocr} icon={<Description />} />
|
|
<StatusItem label="VLM Analysis" status={processingStatus.docling_vlm} icon={<Psychology />} />
|
|
</Box>
|
|
|
|
{manifest && (
|
|
<Box sx={{ mt: 3 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Document Info</Typography>
|
|
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
|
|
{manifest.page_count} pages
|
|
</Typography>
|
|
{outline.length > 0 && (
|
|
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>
|
|
{outline.length} sections identified
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
// Render document structure tree
|
|
const renderStructureTree = () => (
|
|
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
|
{sectionTree.length === 0 ? (
|
|
<Box sx={{ p: 2, color: 'var(--color-text-2)', textAlign: 'center' }}>
|
|
<Typography variant="body2">
|
|
{processingStatus.structure_analysis ? 'No document structure detected' : 'Structure analysis pending...'}
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<List dense sx={{ py: 0 }}>
|
|
{sectionTree.map((node) => (
|
|
<SectionTreeItem
|
|
key={node.sec.id}
|
|
node={node}
|
|
level={1}
|
|
selectedPage={selectedPage}
|
|
onSelectPage={onSelectPage}
|
|
collapsed={collapsed}
|
|
onToggleCollapse={(id) => {
|
|
const newCollapsed = new Set(collapsed);
|
|
if (newCollapsed.has(id)) {
|
|
newCollapsed.delete(id);
|
|
} else {
|
|
newCollapsed.add(id);
|
|
}
|
|
setCollapsed(newCollapsed);
|
|
}}
|
|
processingStatus={processingStatus}
|
|
/>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
// Render page thumbnails with lazy loading
|
|
const renderThumbnails = () => {
|
|
if (!manifest) return null;
|
|
|
|
const pages = Array.from({ length: manifest.page_count }, (_, i) => i + 1);
|
|
|
|
return (
|
|
<Box
|
|
ref={thumbnailsRef}
|
|
sx={{
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
p: 1,
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
|
gap: 1,
|
|
alignContent: 'start'
|
|
}}
|
|
>
|
|
{pages.map((page) => (
|
|
<LazyThumbnail
|
|
key={page}
|
|
page={page}
|
|
isSelected={page === selectedPage}
|
|
isInSection={currentSection ? page >= currentSection.start && page <= currentSection.end : false}
|
|
fetchThumbnail={fetchThumbnail}
|
|
onSelect={onSelectPage}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
|
<CircularProgress size={24} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Box sx={{ p: 2, color: 'var(--color-error)' }}>
|
|
<Typography variant="body2">{error}</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
{/* Header with navigation tabs */}
|
|
<Box sx={{
|
|
borderBottom: '1px solid var(--color-divider)',
|
|
bgcolor: 'var(--color-panel)',
|
|
p: 1
|
|
}}>
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setSelectedView('overview')}
|
|
color={selectedView === 'overview' ? 'primary' : 'default'}
|
|
title="Processing Overview"
|
|
>
|
|
<CalendarViewMonth />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setSelectedView('structure')}
|
|
color={selectedView === 'structure' ? 'primary' : 'default'}
|
|
title="Document Structure"
|
|
>
|
|
<Description />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setSelectedView('thumbnails')}
|
|
color={selectedView === 'thumbnails' ? 'primary' : 'default'}
|
|
title="Page Thumbnails"
|
|
>
|
|
<Visibility />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Content based on selected view */}
|
|
{selectedView === 'overview' && renderOverview()}
|
|
{selectedView === 'structure' && renderStructureTree()}
|
|
{selectedView === 'thumbnails' && renderThumbnails()}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// Status indicator component
|
|
const StatusItem: React.FC<{
|
|
label: string;
|
|
status: boolean;
|
|
icon?: React.ReactNode;
|
|
}> = ({ label, status, icon }) => (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{icon || <Description fontSize="small" />}
|
|
<Typography variant="body2" sx={{ flex: 1 }}>
|
|
{label}
|
|
</Typography>
|
|
{status ? (
|
|
<Check fontSize="small" color="success" />
|
|
) : (
|
|
<Schedule fontSize="small" sx={{ color: 'var(--color-text-3)' }} />
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
// Section tree item component
|
|
const SectionTreeItem: React.FC<{
|
|
node: SectionNode;
|
|
level: number;
|
|
selectedPage: number;
|
|
onSelectPage: (page: number) => void;
|
|
collapsed: Set<string>;
|
|
onToggleCollapse: (id: string) => void;
|
|
processingStatus: ProcessingStatus;
|
|
}> = ({ node, level, selectedPage, onSelectPage, collapsed, onToggleCollapse, processingStatus }) => {
|
|
const isCollapsed = collapsed.has(node.sec.id);
|
|
const hasChildren = node.children.length > 0;
|
|
const isCurrentSection = selectedPage >= node.sec.start_page && selectedPage <= node.sec.end_page;
|
|
|
|
return (
|
|
<>
|
|
<ListItem disablePadding>
|
|
<ListItemButton
|
|
sx={{
|
|
pl: level * 2,
|
|
py: 0.5,
|
|
bgcolor: isCurrentSection ? 'var(--color-selected)' : 'transparent',
|
|
'&:hover': { bgcolor: 'var(--color-hover)' }
|
|
}}
|
|
onClick={() => onSelectPage(node.sec.start_page)}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 24 }}>
|
|
{hasChildren ? (
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleCollapse(node.sec.id);
|
|
}}
|
|
>
|
|
{isCollapsed ? <ChevronRight /> : <ExpandMore />}
|
|
</IconButton>
|
|
) : (
|
|
<Box sx={{ width: 24 }} />
|
|
)}
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={node.sec.title || `Section ${node.sec.start_page}`}
|
|
secondary={`Pages ${node.sec.start_page}-${node.sec.end_page}`}
|
|
primaryTypographyProps={{
|
|
variant: 'body2',
|
|
sx: { fontWeight: isCurrentSection ? 600 : 400 }
|
|
}}
|
|
secondaryTypographyProps={{ variant: 'caption' }}
|
|
/>
|
|
{/* Processing indicators */}
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
{processingStatus.docling_ocr && <Chip size="small" label="OCR" sx={{ fontSize: '10px' }} />}
|
|
{processingStatus.docling_no_ocr && <Chip size="small" label="Text" sx={{ fontSize: '10px' }} />}
|
|
{processingStatus.docling_vlm && <Chip size="small" label="VLM" sx={{ fontSize: '10px' }} />}
|
|
</Box>
|
|
</ListItemButton>
|
|
</ListItem>
|
|
|
|
{hasChildren && !isCollapsed && (
|
|
<Collapse in={!isCollapsed} timeout="auto">
|
|
{node.children.map((child) => (
|
|
<SectionTreeItem
|
|
key={child.sec.id}
|
|
node={child}
|
|
level={level + 1}
|
|
selectedPage={selectedPage}
|
|
onSelectPage={onSelectPage}
|
|
collapsed={collapsed}
|
|
onToggleCollapse={onToggleCollapse}
|
|
processingStatus={processingStatus}
|
|
/>
|
|
))}
|
|
</Collapse>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Lazy loading thumbnail component
|
|
const LazyThumbnail: React.FC<{
|
|
page: number;
|
|
isSelected: boolean;
|
|
isInSection: boolean;
|
|
fetchThumbnail: (page: number) => Promise<string | undefined>;
|
|
onSelect: (page: number) => void;
|
|
}> = ({ page, isSelected, isInSection, fetchThumbnail, onSelect }) => {
|
|
const [src, setSrc] = useState<string | undefined>(undefined);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const imgRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Intersection observer for lazy loading
|
|
useEffect(() => {
|
|
if (!imgRef.current) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setIsVisible(true);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ threshold: 0.1, rootMargin: '50px' }
|
|
);
|
|
|
|
observer.observe(imgRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Load thumbnail when visible
|
|
useEffect(() => {
|
|
if (isVisible && !src) {
|
|
fetchThumbnail(page).then(setSrc);
|
|
}
|
|
}, [isVisible, page, fetchThumbnail, src]);
|
|
|
|
return (
|
|
<Box
|
|
ref={imgRef}
|
|
onClick={() => onSelect(page)}
|
|
sx={{
|
|
aspectRatio: '3/4',
|
|
border: isSelected ? '2px solid var(--color-primary)' : '1px solid var(--color-divider)',
|
|
borderRadius: 1,
|
|
overflow: 'hidden',
|
|
cursor: 'pointer',
|
|
position: 'relative',
|
|
bgcolor: isInSection ? 'var(--color-selected)' : 'var(--color-panel)',
|
|
'&:hover': { borderColor: 'var(--color-primary-light)' },
|
|
transition: 'border-color 0.2s'
|
|
}}
|
|
>
|
|
{src ? (
|
|
<img
|
|
src={src}
|
|
alt={`Page ${page}`}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
display: 'block'
|
|
}}
|
|
/>
|
|
) : isVisible ? (
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%'
|
|
}}>
|
|
<CircularProgress size={16} />
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: 2,
|
|
right: 2,
|
|
bgcolor: 'rgba(0,0,0,0.7)',
|
|
color: 'white',
|
|
px: 0.5,
|
|
py: 0.25,
|
|
borderRadius: 0.5,
|
|
fontSize: '11px',
|
|
lineHeight: 1
|
|
}}
|
|
>
|
|
{page}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default CCEnhancedFilePanel;
|