-
- Generate Summary
-
+ style={{
+ position: 'relative',
+ width: '100%',
+ maxWidth: '360px',
+ backgroundColor: 'var(--color-panel)',
+ border: '1px solid var(--color-divider)',
+ borderRadius: '10px',
+ boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
+ overflow: 'hidden',
+ zIndex: 1,
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+
+ Generate Summary
-
+
-
- {/* Config status indicator */}
- {llmConfig.apiKey ? (
- <>β Configured: {llmConfig.provider} ({llmConfig.model || 'default model'})>
- ) : (
- <>β No API key configured. Click the β icon to set up.>
- )}
+ {llmConfig.model
+ ? <>β {llmConfig.provider} Β· {llmConfig.model}>
+ : <>β No model configured β open Settings first>
+ }
diff --git a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx
index 623d452..945a872 100644
--- a/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx
+++ b/src/utils/tldraw/ui-overrides/components/shared/LLMConfigModal.tsx
@@ -1,15 +1,49 @@
import React, { useState, useEffect } from 'react';
-import Close from '@mui/icons-material/Close';
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
const PROVIDERS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
- { value: 'ollama', label: 'Ollama' },
+ { value: 'ollama', label: 'Ollama (local)' },
{ value: 'openrouter', label: 'OpenRouter' },
- { value: 'google', label: 'Google' },
+ { value: 'google', label: 'Google Gemini' },
] as const;
+const WHISPER_MODELS = [
+ { value: 'tiny', label: 'Tiny (fastest, least accurate)' },
+ { value: 'tiny.en', label: 'Tiny English' },
+ { value: 'base', label: 'Base' },
+ { value: 'base.en', label: 'Base English' },
+ { value: 'small', label: 'Small' },
+ { value: 'small.en', label: 'Small English' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'medium.en', label: 'Medium English' },
+ { value: 'large-v2', label: 'Large v2' },
+ { value: 'large-v3', label: 'Large v3 (best accuracy)' },
+];
+
+const fieldStyle: React.CSSProperties = {
+ width: '100%',
+ padding: '7px 10px',
+ border: '1px solid var(--color-divider)',
+ borderRadius: '6px',
+ backgroundColor: 'var(--color-muted)',
+ color: 'var(--color-text)',
+ fontSize: '13px',
+ outline: 'none',
+ boxSizing: 'border-box',
+};
+
+const labelStyle: React.CSSProperties = {
+ display: 'block',
+ fontSize: '12px',
+ fontWeight: 600,
+ color: 'var(--color-text-2)',
+ marginBottom: '4px',
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+};
+
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
const { llmConfig, setLLMConfig } = useTranscriptionStore();
const [form, setForm] = useState
(llmConfig);
@@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
const handleSave = () => {
setLLMConfig(form);
setSaved(true);
- setTimeout(() => setSaved(false), 2000);
+ setTimeout(() => {
+ setSaved(false);
+ onClose();
+ }, 1000);
};
if (!isOpen) return null;
return (
-
+
{ if (e.target === e.currentTarget) onClose(); }}
+ >
{/* Backdrop */}
-
+
{/* Modal panel */}
-
+
e.stopPropagation()}
+ >
{/* Header */}
-
-
- LLM Provider Settings
-
+
+
+ Settings
+
{/* Content */}
-
- {/* Provider dropdown */}
+
+
+ {/* ββ Transcription section ββ */}
-
- Provider
-
+
+ Transcription
+
+
Whisper Model
+
+ Larger models are more accurate but slower to load. Server has large-v3 downloaded.
+
- {/* Model name */}
+ {/* ββ LLM section ββ */}
-
- Model
-
- setForm({ ...form, model: e.target.value })}
- placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- />
-
+
+ AI Summary Provider
+
- {/* API Key */}
-
-
- API Key
-
- setForm({ ...form, apiKey: e.target.value })}
- placeholder="sk-..."
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- />
-
+
+
+ Provider
+
+
- {/* Note */}
-
- API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
-
+
+ Model
+ setForm({ ...form, model: e.target.value })}
+ placeholder={
+ form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' :
+ form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' :
+ form.provider === 'google' ? 'e.g. gemini-2.0-flash' :
+ 'e.g. gpt-4o, gpt-4o-mini'
+ }
+ style={fieldStyle}
+ />
+
+
+ {form.provider === 'ollama' && (
+
+ Ollama Base URL
+ setForm({ ...form, baseUrl: e.target.value })}
+ placeholder="https://ollama.kevlarai.com"
+ style={fieldStyle}
+ />
+
+ )}
+
+
+
+ {form.provider === 'ollama' ? 'API Key (optional β leave blank if unrestricted)' : 'API Key'}
+
+ setForm({ ...form, apiKey: e.target.value })}
+ placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'}
+ style={fieldStyle}
+ />
+
+
+
+
+ API keys are stored in your browser only.
+
+
{/* Save button */}
diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx
new file mode 100644
index 0000000..9e1e5b0
--- /dev/null
+++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx
@@ -0,0 +1,586 @@
+import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
+import {
+ Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
+ ToggleButtonGroup, ToggleButton,
+} from '@mui/material';
+import {
+ ExpandMore, ChevronRight as ChevronRightIcon,
+ Home as HomeIcon,
+ CalendarToday, DateRange, Event,
+ Schedule as TimetableIcon,
+ Class as ClassIcon,
+ MenuBook as CurriculumIcon,
+ Book as JournalIcon,
+ EventNote as PlannerIcon,
+ Business as SchoolIcon,
+ LinkOff as UnlinkedIcon,
+ HourglassEmpty as PendingIcon,
+ School as AcademicIcon,
+ GridOn as GridIcon,
+ Settings as SetupIcon,
+ Edit as EditIcon,
+} from '@mui/icons-material';
+import { useNavigationStore } from '../../../../../../stores/navigationStore';
+import { useAuth } from '../../../../../../contexts/AuthContext';
+import { NeoGraphNode } from '../../../../../../types/navigation';
+import { logger } from '../../../../../../debugConfig';
+import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard';
+import { TeacherTimetableWizard, PeriodTemplate } from './TeacherTimetableWizard';
+
+type NodeStatus = 'populated' | 'empty' | 'no_school' | 'not_initialized';
+type CalendarMode = 'generic' | 'academic';
+
+interface TreeNode extends NeoGraphNode {
+ has_children?: boolean;
+ children?: TreeNode[];
+ is_section?: boolean;
+ section_id?: string;
+ status?: NodeStatus;
+ neo4j_props?: Record
;
+}
+
+interface SchoolStatus {
+ status: string;
+ user_role?: string;
+ school_id?: string;
+ school_has_calendar?: boolean;
+ teacher_has_timetable?: boolean;
+ timetable_id?: string | null;
+ periods_template?: PeriodTemplate[] | null;
+ school_info?: SchoolInfo;
+}
+
+const NODE_ICONS: Record = {
+ User: HomeIcon,
+ CalendarYear: CalendarToday,
+ CalendarMonth: DateRange,
+ CalendarWeek: DateRange,
+ CalendarDay: Event,
+ AcademicYear: AcademicIcon,
+ AcademicTerm: AcademicIcon,
+ AcademicWeek: DateRange,
+ TeacherTimetable: TimetableIcon,
+ SubjectClass: ClassIcon,
+ TimetableLesson: TimetableIcon,
+ TimetableSlot: GridIcon,
+ Journal: JournalIcon,
+ Planner: PlannerIcon,
+ School: SchoolIcon,
+ Department: SchoolIcon,
+ Section: HomeIcon,
+};
+
+const SECTION_ICONS: Record = {
+ calendar: CalendarToday,
+ timetable: TimetableIcon,
+ classes: ClassIcon,
+ curriculum: CurriculumIcon,
+ journal: JournalIcon,
+ planner: PlannerIcon,
+ school: SchoolIcon,
+};
+
+const STATUS_MESSAGES: Record = {
+ populated: '',
+ empty: 'Not set up yet',
+ no_school: 'Join a school to unlock',
+ not_initialized: 'Setting up...',
+};
+
+// βββ Panel context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface NavPanelContextValue {
+ calendarMode: CalendarMode;
+ setCalendarMode: (m: CalendarMode) => void;
+ academicCalendarStatus: 'idle' | 'loading' | 'populated' | 'empty' | 'no_school' | 'error';
+ academicTerms: TreeNode[];
+ schoolStatus: SchoolStatus | null;
+ onSetupSchoolCalendar: () => void;
+ onSetupTimetable: () => void;
+ activeNodeId?: string;
+}
+
+const NavPanelContext = createContext({
+ calendarMode: 'generic',
+ setCalendarMode: () => {},
+ academicCalendarStatus: 'idle',
+ academicTerms: [],
+ schoolStatus: null,
+ onSetupSchoolCalendar: () => {},
+ onSetupTimetable: () => {},
+});
+
+// βββ TreeItem βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface TreeItemProps {
+ node: TreeNode;
+ depth: number;
+ onSelect: (node: TreeNode) => void;
+ onExpand: (node: TreeNode) => Promise;
+}
+
+function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
+ const ctx = useContext(NavPanelContext);
+ const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated');
+ const [children, setChildren] = useState(node.children || []);
+ const [loading, setLoading] = useState(false);
+
+ const isSection = !!node.is_section;
+ const isCalendarSection = isSection && node.section_id === 'calendar';
+ const isTimetableSection = isSection && node.section_id === 'timetable';
+ const isSchoolSection = isSection && node.section_id === 'school';
+ const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null;
+ const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon;
+
+ const canExpand = node.has_children !== false
+ && node.node_type !== 'CalendarDay'
+ && node.node_type !== 'AcademicWeek'
+ && node.status !== 'empty'
+ && node.status !== 'no_school'
+ && node.status !== 'not_initialized';
+
+ const isActive = !isSection && node.neo4j_node_id === ctx.activeNodeId;
+ const isEmpty = node.status === 'empty' || node.status === 'no_school' || node.status === 'not_initialized';
+
+ const displayChildren = isCalendarSection && ctx.calendarMode === 'academic'
+ ? ctx.academicTerms
+ : children;
+
+ const academicEmpty = isCalendarSection
+ && ctx.calendarMode === 'academic'
+ && (ctx.academicCalendarStatus === 'empty' || ctx.academicCalendarStatus === 'idle');
+
+ const handleToggle = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!expanded && displayChildren.length === 0 && canExpand && !isCalendarSection) {
+ setLoading(true);
+ try {
+ const loaded = await onExpand(node);
+ setChildren(loaded);
+ } finally {
+ setLoading(false);
+ }
+ }
+ setExpanded(v => !v);
+ };
+
+ const handleClick = () => {
+ if (!isSection) {
+ onSelect(node);
+ } else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
+ handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
+ }
+ };
+
+ // Derive action buttons per section
+ const ss = ctx.schoolStatus;
+ // School section: calendar setup (admin) or pending notice (non-admin)
+ const showCalendarSetup = isSchoolSection
+ && ss && ss.status !== 'no_school'
+ && !ss.school_has_calendar && ss.user_role === 'school_admin';
+ const showCalendarPending = isSchoolSection
+ && ss && ss.status !== 'no_school'
+ && !ss.school_has_calendar && ss.user_role !== 'school_admin';
+ // Timetable section: teacher timetable setup (requires school calendar first)
+ const showTimetableSetup = isTimetableSection && node.status === 'empty'
+ && ss && ss.status !== 'no_school'
+ && ss.school_has_calendar && !ss.teacher_has_timetable;
+ const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss;
+ const showTimetableEdit = isTimetableSection && node.status === 'populated'
+ && ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable;
+
+ if (isSection) {
+ return (
+
+
+
+ {(canExpand || (isCalendarSection && !academicEmpty)) && (
+ loading
+ ?
+ : (
+
+ {expanded
+ ?
+ : }
+
+ )
+ )}
+ {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && (
+
+ {node.status === 'no_school'
+ ?
+ : node.status === 'not_initialized'
+ ?
+ : null}
+
+ )}
+
+
+
+
+
+ {node.label}
+
+
+ {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && node.status && (
+
+
+ {node.status === 'no_school' ? 'β' : 'β¦'}
+
+
+ )}
+
+ {/* Timetable section β role-aware action */}
+ {showCalendarSetup && (
+
+ { e.stopPropagation(); ctx.onSetupSchoolCalendar(); }}
+ >
+
+
+
+ )}
+ {showCalendarPending && (
+
+
+
+ )}
+ {showTimetableSetup && (
+
+ { e.stopPropagation(); ctx.onSetupTimetable(); }}
+ >
+
+
+
+ )}
+ {showLegacySetup && (
+
+ { e.stopPropagation(); ctx.onSetupTimetable(); }}
+ >
+
+
+
+ )}
+ {showTimetableEdit && (
+
+ { e.stopPropagation(); ctx.onSetupTimetable(); }}
+ >
+
+
+
+ )}
+
+
+ {/* Calendar mode toggle */}
+ {isCalendarSection && (
+
+ { if (v) ctx.setCalendarMode(v); }}
+ size="small"
+ sx={{ height: 22 }}
+ >
+
+ Generic
+
+
+ Academic
+
+
+ {ctx.calendarMode === 'academic' && academicEmpty && (
+
+ No academic calendar β set up school calendar first
+
+ )}
+ {ctx.calendarMode === 'academic' && ctx.academicCalendarStatus === 'loading' && (
+
+ )}
+
+ )}
+
+ {(canExpand || (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0)) && (
+
+ {displayChildren.map(child => (
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Regular navigable node
+ return (
+
+
+
+ {canExpand && (
+ loading
+ ?
+ : (
+
+ {expanded
+ ?
+ : }
+
+ )
+ )}
+
+
+
+ {node.label}
+
+
+
+ {canExpand && (
+
+ {children.map(child => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// βββ Main Panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export function CCGraphNavPanel() {
+ const { accessToken } = useAuth();
+ const { navigateToNeoNode, context } = useNavigationStore();
+ const [tree, setTree] = useState(null);
+ const [schoolStatus, setSchoolStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [calendarMode, setCalendarMode] = useState('generic');
+ const [academicCalendarStatus, setAcademicCalendarStatus] = useState('idle');
+ const [academicTerms, setAcademicTerms] = useState([]);
+
+ const [calendarWizardOpen, setCalendarWizardOpen] = useState(false);
+ const [timetableWizardOpen, setTimetableWizardOpen] = useState(false);
+
+ const apiBase = import.meta.env.VITE_API_BASE as string;
+ const activeNodeId = context.node?.type !== 'workspace' ? context.node?.id : undefined;
+
+ const fetchTree = useCallback(async () => {
+ if (!accessToken) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${apiBase}/graph/tree`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ const data = await res.json();
+ setTree(data.tree);
+ } catch (err) {
+ logger.error('graph-nav-panel', 'Failed to load graph tree', err);
+ setError('Failed to load navigation tree');
+ } finally {
+ setLoading(false);
+ }
+ }, [accessToken, apiBase]);
+
+ const fetchSchoolStatus = useCallback(async () => {
+ if (!accessToken) return;
+ try {
+ const res = await fetch(`${apiBase}/school/status`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ setSchoolStatus(data);
+ } catch {
+ // non-fatal β panel still works without school status
+ }
+ }, [accessToken, apiBase]);
+
+ useEffect(() => {
+ if (accessToken && !tree) fetchTree();
+ }, [accessToken, tree, fetchTree]);
+
+ useEffect(() => {
+ if (accessToken && !schoolStatus) fetchSchoolStatus();
+ }, [accessToken, schoolStatus, fetchSchoolStatus]);
+
+ // Fetch academic calendar when switching to academic mode
+ useEffect(() => {
+ if (calendarMode !== 'academic' || !accessToken) return;
+ if (academicCalendarStatus !== 'idle') return;
+ setAcademicCalendarStatus('loading');
+ fetch(`${apiBase}/graph/calendar/academic`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'populated') {
+ setAcademicTerms(data.terms);
+ setAcademicCalendarStatus('populated');
+ } else {
+ setAcademicCalendarStatus(data.status || 'empty');
+ }
+ })
+ .catch(() => setAcademicCalendarStatus('error'));
+ }, [calendarMode, accessToken, apiBase, academicCalendarStatus]);
+
+ const handleSetCalendarMode = useCallback((m: CalendarMode) => {
+ setCalendarMode(m);
+ if (m === 'academic') setAcademicCalendarStatus('idle');
+ }, []);
+
+ const handleExpand = useCallback(async (node: TreeNode): Promise => {
+ if (!accessToken) return [];
+ const params = new URLSearchParams({
+ neo4j_node_id: node.neo4j_node_id,
+ neo4j_db_name: node.neo4j_db_name,
+ node_type: node.node_type,
+ section_id: node.section_id || '',
+ });
+ try {
+ const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return data.children || [];
+ } catch {
+ return [];
+ }
+ }, [accessToken, apiBase]);
+
+ const handleSelect = useCallback((node: TreeNode) => {
+ if (!node.is_section) navigateToNeoNode(node);
+ }, [navigateToNeoNode]);
+
+ const refreshAll = useCallback(() => {
+ setTree(null);
+ setSchoolStatus(null);
+ setAcademicCalendarStatus('idle');
+ setAcademicTerms([]);
+ }, []);
+
+ const handleCalendarWizardComplete = useCallback(() => {
+ logger.info('graph-nav-panel', 'School calendar setup complete');
+ refreshAll();
+ }, [refreshAll]);
+
+ const handleTimetableWizardComplete = useCallback((timetableId: string) => {
+ logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
+ refreshAll();
+ }, [refreshAll]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (!tree) return null;
+
+ const ctxValue: NavPanelContextValue = {
+ calendarMode,
+ setCalendarMode: handleSetCalendarMode,
+ academicCalendarStatus,
+ academicTerms,
+ schoolStatus,
+ onSetupSchoolCalendar: () => setCalendarWizardOpen(true),
+ onSetupTimetable: () => setTimetableWizardOpen(true),
+ activeNodeId,
+ };
+
+ const defaultSchoolInfo: SchoolInfo = {
+ name: '', urn: '', website: '', address: {},
+ headteacher: '', term_dates_url: '', staff_list_url: '',
+ };
+
+ return (
+
+
+
+
+
+ {schoolStatus?.school_info && (
+ setCalendarWizardOpen(false)}
+ onComplete={handleCalendarWizardComplete}
+ apiBase={apiBase}
+ schoolInfo={schoolStatus.school_info || defaultSchoolInfo}
+ />
+ )}
+
+ setTimetableWizardOpen(false)}
+ onComplete={handleTimetableWizardComplete}
+ apiBase={apiBase}
+ periodsTemplate={schoolStatus?.periods_template || []}
+ timetableId={schoolStatus?.timetable_id || null}
+ />
+
+ );
+}
diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx
index 9d154a5..763ffaf 100644
--- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx
+++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx
@@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save';
import Reset from '@mui/icons-material/RestartAlt';
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
-import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
+import { useAuth } from '../../../../../../contexts/AuthContext';
import { PageComponent } from '../components/pageComponent';
import { logger } from '../../../../../../debugConfig';
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
@@ -68,6 +68,7 @@ export const CCNodeSnapshotPanel: React.FC = () => {
const editor = useEditor();
const { addToast } = useToasts();
const { context: navigationContext, isLoading, error } = useNavigationStore();
+ const { accessToken } = useAuth();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [isSaving, setIsSaving] = useState(false);
@@ -131,8 +132,9 @@ export const CCNodeSnapshotPanel: React.FC = () => {
type: navigationContext.node.type
});
- const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
- await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
+ const storagePath = navigationContext.node.node_storage_path;
+ if (!storagePath) throw new Error('No storage path on current node');
+ await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store);
addToast({
title: 'Snapshot saved',
diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx
new file mode 100644
index 0000000..2f16a4b
--- /dev/null
+++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx
@@ -0,0 +1,316 @@
+import React, { useState } from 'react';
+import {
+ Dialog, DialogTitle, DialogContent, DialogActions,
+ Button, Stepper, Step, StepLabel, Box, TextField,
+ Typography, IconButton, Select, MenuItem, FormControl,
+ InputLabel, CircularProgress, Alert, Divider,
+} from '@mui/material';
+import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
+import { useAuth } from '../../../../../../contexts/AuthContext';
+
+interface TermInput {
+ name: string;
+ term_number: number;
+ start_date: string;
+ end_date: string;
+}
+
+interface PeriodInput {
+ code: string;
+ name: string;
+ start_time: string;
+ end_time: string;
+ period_type: 'lesson' | 'break' | 'registration';
+}
+
+export interface SchoolInfo {
+ name: string;
+ urn: string;
+ website: string;
+ address: Record;
+ headteacher: string;
+ term_dates_url: string;
+ staff_list_url: string;
+}
+
+const DEFAULT_TERMS: TermInput[] = [
+ { name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' },
+ { name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' },
+ { name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' },
+];
+
+const DEFAULT_PERIODS: PeriodInput[] = [
+ { code: 'REG', name: 'Registration', start_time: '08:45', end_time: '09:00', period_type: 'registration' },
+ { code: 'P1', name: 'Period 1', start_time: '09:00', end_time: '10:00', period_type: 'lesson' },
+ { code: 'P2', name: 'Period 2', start_time: '10:00', end_time: '11:00', period_type: 'lesson' },
+ { code: 'BREAK', name: 'Break', start_time: '11:00', end_time: '11:20', period_type: 'break' },
+ { code: 'P3', name: 'Period 3', start_time: '11:20', end_time: '12:20', period_type: 'lesson' },
+ { code: 'P4', name: 'Period 4', start_time: '12:20', end_time: '13:20', period_type: 'lesson' },
+ { code: 'LUNCH', name: 'Lunch', start_time: '13:20', end_time: '14:05', period_type: 'break' },
+ { code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' },
+];
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onComplete: () => void;
+ apiBase: string;
+ schoolInfo: SchoolInfo;
+}
+
+export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoolInfo }: Props) {
+ const { accessToken } = useAuth();
+ const [step, setStep] = useState(0);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
+ const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
+ const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
+
+ const [yearStart, setYearStart] = useState('2025-09-01');
+ const [yearEnd, setYearEnd] = useState('2026-07-31');
+ const [terms, setTerms] = useState(DEFAULT_TERMS);
+
+ const [periods, setPeriods] = useState(DEFAULT_PERIODS);
+
+ const addTerm = () => setTerms(prev => [...prev, {
+ name: `Term ${prev.length + 1}`,
+ term_number: prev.length + 1,
+ start_date: '',
+ end_date: '',
+ }]);
+
+ const removeTerm = (i: number) => setTerms(prev =>
+ prev.filter((_, idx) => idx !== i).map((t, idx) => ({ ...t, term_number: idx + 1 }))
+ );
+
+ const updateTerm = (i: number, field: keyof TermInput, value: string) =>
+ setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t));
+
+ const addPeriod = () => setPeriods(prev => [...prev, {
+ code: `P${prev.length + 1}`,
+ name: `Period ${prev.length + 1}`,
+ start_time: '',
+ end_time: '',
+ period_type: 'lesson',
+ }]);
+
+ const removePeriod = (i: number) => setPeriods(prev => prev.filter((_, idx) => idx !== i));
+
+ const updatePeriod = (i: number, field: keyof PeriodInput, value: string) =>
+ setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p));
+
+ const handleSaveSchoolInfo = async () => {
+ if (!accessToken) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch(`${apiBase}/school/info`, {
+ method: 'PATCH',
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ headteacher, term_dates_url: termDatesUrl, staff_list_url: staffListUrl }),
+ });
+ const data = await res.json();
+ if (data.status === 'ok') {
+ setStep(1);
+ } else {
+ setError(data.message || 'Failed to save school info');
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveCalendar = async () => {
+ if (!accessToken) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch(`${apiBase}/timetable/setup`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }),
+ });
+ const data = await res.json();
+ if (data.status === 'ok') {
+ onComplete();
+ handleClose();
+ } else {
+ setError(data.message || 'Calendar setup failed');
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleClose = () => {
+ setStep(0);
+ setError(null);
+ onClose();
+ };
+
+ const addr = schoolInfo.address || {};
+ const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', ');
+
+ const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods'];
+
+ return (
+
+ );
+}
diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx
new file mode 100644
index 0000000..0877f58
--- /dev/null
+++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx
@@ -0,0 +1,244 @@
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Dialog, DialogTitle, DialogContent, DialogActions,
+ Button, Box, TextField, Typography, Table, TableHead,
+ TableBody, TableRow, TableCell, CircularProgress, Alert,
+} from '@mui/material';
+import { useAuth } from '../../../../../../contexts/AuthContext';
+
+export interface PeriodTemplate {
+ code: string;
+ name: string;
+ start_time: string;
+ end_time: string;
+ period_type: string;
+}
+
+const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
+
+function emptyGrid(): Record> {
+ const g: Record> = {};
+ DAYS.forEach(d => { g[d] = {}; });
+ return g;
+}
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onComplete: (timetableId: string) => void;
+ apiBase: string;
+ periodsTemplate: PeriodTemplate[];
+ timetableId: string | null;
+}
+
+export function TeacherTimetableWizard({
+ open,
+ onClose,
+ onComplete,
+ apiBase,
+ periodsTemplate,
+ timetableId: initialTimetableId,
+}: Props) {
+ const { accessToken } = useAuth();
+ const [localTimetableId, setLocalTimetableId] = useState(initialTimetableId);
+ const [initializing, setInitializing] = useState(false);
+ const [loadingSlots, setLoadingSlots] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [grid, setGrid] = useState>>(emptyGrid);
+ const slotsLoadedRef = useRef(false);
+
+ const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
+ const isEditing = !!initialTimetableId;
+
+ // Reset when dialog opens
+ useEffect(() => {
+ if (!open) {
+ slotsLoadedRef.current = false;
+ return;
+ }
+ setLocalTimetableId(initialTimetableId);
+ setGrid(emptyGrid());
+ setError(null);
+ slotsLoadedRef.current = false;
+ }, [open, initialTimetableId]);
+
+ // Auto-create TeacherTimetable node if not yet done
+ useEffect(() => {
+ if (!open || localTimetableId || !accessToken || initializing) return;
+ setInitializing(true);
+ setError(null);
+ fetch(`${apiBase}/timetable/init`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'ok') {
+ setLocalTimetableId(data.timetable_id);
+ } else {
+ setError(data.message || 'Could not initialize timetable. Has an admin set up the school calendar?');
+ }
+ })
+ .catch(e => setError(e.message))
+ .finally(() => setInitializing(false));
+ }, [open, localTimetableId, accessToken, apiBase, initializing]);
+
+ // Load existing slots when editing
+ useEffect(() => {
+ if (!open || !localTimetableId || !accessToken || slotsLoadedRef.current || loadingSlots) return;
+ slotsLoadedRef.current = true;
+ setLoadingSlots(true);
+ fetch(`${apiBase}/timetable/slots`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'ok' && Array.isArray(data.slots) && data.slots.length > 0) {
+ const g = emptyGrid();
+ for (const slot of data.slots) {
+ if (g[slot.day_of_week]) {
+ g[slot.day_of_week][slot.period_code] = slot.subject_class || '';
+ }
+ }
+ setGrid(g);
+ }
+ })
+ .catch(() => {})
+ .finally(() => setLoadingSlots(false));
+ }, [open, localTimetableId, accessToken, apiBase, loadingSlots]);
+
+ const setCell = (day: string, code: string, value: string) => {
+ setGrid(prev => ({ ...prev, [day]: { ...prev[day], [code]: value } }));
+ };
+
+ const handleSave = async () => {
+ if (!accessToken || !localTimetableId) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const slots = [];
+ for (const day of DAYS) {
+ for (const period of lessonPeriods) {
+ const cls = (grid[day]?.[period.code] || '').trim();
+ if (cls) {
+ slots.push({
+ day_of_week: day,
+ period_code: period.code,
+ subject_class: cls,
+ start_time: period.start_time,
+ end_time: period.end_time,
+ });
+ }
+ }
+ }
+ const res = await fetch(`${apiBase}/timetable/slots`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ timetable_id: localTimetableId, slots }),
+ });
+ const data = await res.json();
+ if (data.status === 'ok') {
+ onComplete(localTimetableId);
+ handleClose();
+ } else {
+ setError(data.message || 'Save failed');
+ }
+ } catch (e: any) {
+ setError(e.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleClose = () => {
+ setError(null);
+ onClose();
+ };
+
+ const busy = initializing || loadingSlots || saving;
+
+ return (
+
+ );
+}