const { useState, useEffect, useRef, useContext, createContext } = React; /* ------------------------------------------------------------------ */ /* API */ /* ------------------------------------------------------------------ */ const BASE = '/api'; async function request(path, options = {}) { const res = await fetch(`${BASE}${path}`, { credentials: 'include', headers: options.body instanceof FormData ? undefined : { 'Content-Type': 'application/json' }, ...options, }); if (res.status === 401) { const err = new Error('unauthorized'); err.unauthorized = true; throw err; } if (!res.ok) throw new Error(`API error ${res.status} on ${path}`); if (res.status === 204) return null; return res.json(); } const api = { // Auth me: () => request('/auth/me'), login: (username, password) => request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => request('/auth/logout', { method: 'POST' }), // Projects getProjects: () => request('/projects'), getProject: (id) => request(`/projects/${id}`), createProject: (data) => request('/projects', { method: 'POST', body: JSON.stringify(data) }), updateProject: (id, data) => request(`/projects/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }), getProjectSkills: (id) => request(`/projects/${id}/skills`), addProjectSkill: (id, skill_id) => request(`/projects/${id}/skills`, { method: 'POST', body: JSON.stringify({ skill_id }) }), removeProjectSkill: (id, linkId) => request(`/projects/${id}/skills/${linkId}`, { method: 'DELETE' }), // Trainings getTrainings: () => request('/trainings'), getTraining: (id) => request(`/trainings/${id}`), createTraining: (data) => request('/trainings', { method: 'POST', body: JSON.stringify(data) }), updateTraining: (id, data) => request(`/trainings/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteTraining: (id) => request(`/trainings/${id}`, { method: 'DELETE' }), getTrainingSkills: (id) => request(`/trainings/${id}/skills`), addTrainingSkill: (id, skill_id) => request(`/trainings/${id}/skills`, { method: 'POST', body: JSON.stringify({ skill_id }) }), removeTrainingSkill: (id, linkId) => request(`/trainings/${id}/skills/${linkId}`, { method: 'DELETE' }), // Skills getSkills: () => request('/skills'), getSkill: (id) => request(`/skills/${id}`), createSkill: (data) => request('/skills', { method: 'POST', body: JSON.stringify(data) }), updateSkill: (id, data) => request(`/skills/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteSkill: (id) => request(`/skills/${id}`, { method: 'DELETE' }), uploadSkillVideo: (id, file) => { const fd = new FormData(); fd.append('video', file); return request(`/skills/${id}/video`, { method: 'POST', body: fd }); }, uploadSkillFrames: (id, blobs) => { const fd = new FormData(); blobs.forEach((b, i) => fd.append('frames', b, `frame-${i}.jpg`)); return request(`/skills/${id}/frames`, { method: 'POST', body: fd }); }, deleteSkillFrame: (id, frameId) => request(`/skills/${id}/frames/${frameId}`, { method: 'DELETE' }), }; /* ------------------------------------------------------------------ */ /* Tiny hash router */ /* ------------------------------------------------------------------ */ function navigate(path) { window.location.hash = path; } function useRoute() { const [hash, setHash] = useState(window.location.hash || '#/projects'); useEffect(() => { const onChange = () => setHash(window.location.hash || '#/projects'); window.addEventListener('hashchange', onChange); return () => window.removeEventListener('hashchange', onChange); }, []); const path = (hash.replace(/^#/, '') || '/projects'); const parts = path.split('/').filter(Boolean); if (parts[0] === 'projects' && parts.length === 1) return { page: 'projectsList' }; if (parts[0] === 'projects' && parts.length === 2) return { page: 'projectDetail', id: parts[1] }; if (parts[0] === 'training' && parts.length === 1) return { page: 'trainingList' }; if (parts[0] === 'training' && parts.length === 2) return { page: 'trainingDetail', id: parts[1] }; if (parts[0] === 'skills' && parts.length === 1) return { page: 'skillsList' }; if (parts[0] === 'skills' && parts[1] === 'new') return { page: 'skillForm' }; if (parts[0] === 'skills' && parts.length === 3 && parts[2] === 'edit') return { page: 'skillForm', id: parts[1] }; return { page: 'projectsList' }; } /* ------------------------------------------------------------------ */ /* Auth context */ /* ------------------------------------------------------------------ */ const AuthContext = createContext({ authenticated: false, loading: true }); function AuthProvider({ children }) { const [authenticated, setAuthenticated] = useState(false); const [loading, setLoading] = useState(true); const [loginOpen, setLoginOpen] = useState(false); useEffect(() => { api.me().then((r) => setAuthenticated(r.authenticated)).finally(() => setLoading(false)); }, []); const login = async (username, password) => { const r = await api.login(username, password); setAuthenticated(true); setLoginOpen(false); return r; }; const logout = async () => { await api.logout(); setAuthenticated(false); }; return ( {children} {loginOpen && } ); } function useAuth() { return useContext(AuthContext); } function LoginModal() { const { login, setLoginOpen } = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setError(''); setBusy(true); try { await login(username, password); } catch (err) { setError('Wrong username or password.'); } finally { setBusy(false); } }; return (
setLoginOpen(false)}>
e.stopPropagation()}>

Log in to edit

setUsername(e.target.value)} autoFocus />
setPassword(e.target.value)} />
{error &&
{error}
}
Anyone can view β€” logging in is only needed to add, edit, or delete.
); } /* ------------------------------------------------------------------ */ /* Shared components */ /* ------------------------------------------------------------------ */ function TabBar({ active }) { const { authenticated, loading, logout, setLoginOpen } = useAuth(); const tabs = [ { key: 'projects', label: 'Projects', path: '#/projects' }, { key: 'training', label: 'Training', path: '#/training' }, { key: 'skills', label: 'Skills', path: '#/skills' }, ]; return (
{tabs.map((t) => ( ))}
{!loading && ( )}
); } function FloatingButton({ onClick, label = '+' }) { const { authenticated } = useAuth(); if (!authenticated) return null; return ( ); } const REVEAL = 112; function SwipeListItem({ colorClass, onTap, onEdit, onDelete, children }) { const { authenticated } = useAuth(); const [dragX, setDragX] = useState(0); const startX = useRef(null); const dragging = useRef(false); const onPointerDown = (e) => { if (!authenticated) return; startX.current = (e.touches ? e.touches[0].clientX : e.clientX); dragging.current = true; }; const onPointerMove = (e) => { if (!dragging.current) return; const x = (e.touches ? e.touches[0].clientX : e.clientX); const delta = x - startX.current; const next = Math.min(0, Math.max(-REVEAL, dragX + delta)); setDragX(next); startX.current = x; }; const endDrag = () => { dragging.current = false; setDragX((x) => (x < -REVEAL / 2 ? -REVEAL : 0)); }; const handleTap = () => { if (dragX !== 0) { setDragX(0); return; } onTap && onTap(); }; return (
{authenticated && (
{onEdit && } {onDelete && }
)}
dragging.current && endDrag()} onTouchStart={onPointerDown} onTouchMove={onPointerMove} onTouchEnd={endDrag} onClick={handleTap} > {children}
); } function PhotoCycler({ frames = [] }) { const [index, setIndex] = useState(0); const startY = useRef(null); const accum = useRef(0); const STEP = 24; const onDown = (e) => { e.stopPropagation(); startY.current = e.touches ? e.touches[0].clientY : e.clientY; accum.current = 0; }; const onMove = (e) => { if (startY.current === null || !frames.length) return; e.stopPropagation(); const y = e.touches ? e.touches[0].clientY : e.clientY; const delta = y - startY.current; startY.current = y; accum.current += delta; if (Math.abs(accum.current) >= STEP) { const steps = Math.trunc(accum.current / STEP); setIndex((i) => (i - steps + frames.length * 100) % frames.length); accum.current = 0; } }; const onUp = (e) => { e.stopPropagation(); startY.current = null; }; return (
{frames.length ? : '🎀'}
); } function VideoModal({ skill, onClose }) { if (!skill) return null; return (
{skill.video_path ? (
); } function SkillPickerModal({ existingIds = [], onPick, onClose }) { const [skills, setSkills] = useState([]); useEffect(() => { api.getSkills().then(setSkills); }, []); return (
e.stopPropagation()}>

Add a skill

{skills.length === 0 &&
No skills yet β€” create one from the Skills tab first.
} {skills.map((s) => { const already = existingIds.includes(s.id); return (
{s.name}
{[s.level, s.tags].filter(Boolean).join(' | ')}
); })}
); } /* ------------------------------------------------------------------ */ /* Pages */ /* ------------------------------------------------------------------ */ function ProjectsList() { const [projects, setProjects] = useState([]); const load = () => api.getProjects().then(setProjects); useEffect(() => { load(); }, []); const createNew = async () => { const p = await api.createProject({ name: 'New Project', song_name: '' }); navigate(`#/projects/${p.id}`); }; return (
{projects.length === 0 &&
No projects yet.
} {projects.map((p) => ( navigate(`#/projects/${p.id}`)} onEdit={() => navigate(`#/projects/${p.id}`)} onDelete={async () => { await api.deleteProject(p.id); load(); }}>
{p.name || 'Project name'}
{p.song_name || 'Song name'}
))}
); } function fmtDate(d) { if (!d) return 'Date'; return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } function TrainingList() { const [trainings, setTrainings] = useState([]); const load = () => api.getTrainings().then(setTrainings); useEffect(() => { load(); }, []); const createNew = async () => { const t = await api.createTraining({ name: 'New Training', training_date: null }); navigate(`#/training/${t.id}`); }; return (
{trainings.length === 0 &&
No training sessions yet.
} {trainings.map((t) => ( navigate(`#/training/${t.id}`)} onEdit={() => navigate(`#/training/${t.id}`)} onDelete={async () => { await api.deleteTraining(t.id); load(); }}>
{t.name || 'Training name'}
{fmtDate(t.training_date)}
))}
); } function SkillsList() { const [skills, setSkills] = useState([]); const [playing, setPlaying] = useState(null); const load = () => api.getSkills().then(setSkills); useEffect(() => { load(); }, []); return (
{skills.length === 0 &&
No skills yet.
} {skills.map((s) => ( setPlaying(s)} onEdit={() => navigate(`#/skills/${s.id}/edit`)} onDelete={async () => { await api.deleteSkill(s.id); load(); }}>
{s.name || 'Name of skill'}
{[s.level, s.tags].filter(Boolean).join(' | ') || 'Level | Tags'}
🎀
))}
navigate('#/skills/new')} /> setPlaying(null)} />
); } function ProjectDetail({ id }) { const { authenticated } = useAuth(); const [project, setProject] = useState(null); const [links, setLinks] = useState([]); const [playing, setPlaying] = useState(null); const [picking, setPicking] = useState(false); const load = async () => { setProject(await api.getProject(id)); setLinks(await api.getProjectSkills(id)); }; useEffect(() => { load(); }, [id]); const saveField = async (field, value) => { setProject((p) => ({ ...p, [field]: value })); await api.updateProject(id, { [field]: value }); }; const addSkill = async (skillId) => { await api.addProjectSkill(id, skillId); setPicking(false); load(); }; const removeSkill = async (linkId) => { await api.removeProjectSkill(id, linkId); load(); }; const deleteProject = async () => { await api.deleteProject(id); navigate('#/projects'); }; if (!project) return null; return (
saveField('name', e.target.value)} /> saveField('song_name', e.target.value)} />
{links.length === 0 &&
No skills added yet.
} {links.map((s) => ( setPlaying(s)} onDelete={() => removeSkill(s.link_id)}>
🎀
{s.name}
{[s.level, s.tags].filter(Boolean).join(' | ')}
))}
setPicking(true)} /> {authenticated && } setPlaying(null)} /> {picking && l.id)} onPick={addSkill} onClose={() => setPicking(false)} />}
); } function toInputDate(d) { if (!d) return ''; return new Date(d).toISOString().slice(0, 10); } function TrainingDetail({ id }) { const { authenticated } = useAuth(); const [training, setTraining] = useState(null); const [links, setLinks] = useState([]); const [playing, setPlaying] = useState(null); const [picking, setPicking] = useState(false); const load = async () => { setTraining(await api.getTraining(id)); setLinks(await api.getTrainingSkills(id)); }; useEffect(() => { load(); }, [id]); const saveField = async (field, value) => { setTraining((t) => ({ ...t, [field]: value })); await api.updateTraining(id, { [field]: value }); }; const addSkill = async (skillId) => { await api.addTrainingSkill(id, skillId); setPicking(false); load(); }; const removeSkill = async (linkId) => { await api.removeTrainingSkill(id, linkId); load(); }; const deleteTraining = async () => { await api.deleteTraining(id); navigate('#/training'); }; if (!training) return null; return (
saveField('name', e.target.value)} /> saveField('training_date', e.target.value)} />
{links.length === 0 &&
No skills added yet.
} {links.map((s) => ( setPlaying(s)} onDelete={() => removeSkill(s.link_id)}>
🎀
{s.name}
{[s.level, s.tags].filter(Boolean).join(' | ')}
))}
setPicking(true)} /> {authenticated && } setPlaying(null)} /> {picking && l.id)} onPick={addSkill} onClose={() => setPicking(false)} />}
); } function SkillForm({ id }) { const { authenticated, setLoginOpen } = useAuth(); const [skillId, setSkillId] = useState(id || null); const [name, setName] = useState(''); const [type, setType] = useState(''); const [level, setLevel] = useState('Beginner'); const [tags, setTags] = useState(''); const [videoPath, setVideoPath] = useState(null); const [frames, setFrames] = useState([]); const [scrub, setScrub] = useState(0); const [duration, setDuration] = useState(0); const [uploading, setUploading] = useState(false); const videoRef = useRef(null); const canvasRef = useRef(null); useEffect(() => { if (!id) return; api.getSkill(id).then((s) => { setName(s.name || ''); setType(s.type || ''); setLevel(s.level || 'Beginner'); setTags(s.tags || ''); setVideoPath(s.video_path); setFrames(s.frames || []); }); }, [id]); if (!authenticated) { return (
Skill
Log in to add or edit skills.
); } const ensureSkillId = async () => { if (skillId) return skillId; const created = await api.createSkill({ name: name || 'New Skill', type, level, tags }); setSkillId(created.id); return created.id; }; const onVideoChosen = async (e) => { const file = e.target.files[0]; if (!file) return; setUploading(true); const sid = await ensureSkillId(); const updated = await api.uploadSkillVideo(sid, file); setVideoPath(updated.video_path); setUploading(false); }; const onLoadedMeta = () => setDuration((videoRef.current && videoRef.current.duration) || 0); const onScrubChange = (e) => { const t = Number(e.target.value); setScrub(t); if (videoRef.current) videoRef.current.currentTime = t; }; const captureFrame = async () => { const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas) return; canvas.width = video.videoWidth || 300; canvas.height = video.videoHeight || 300; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob(async (blob) => { const sid = await ensureSkillId(); const updated = await api.uploadSkillFrames(sid, [blob]); setFrames(updated.frames); }, 'image/jpeg', 0.85); }; const removeFrame = async (frameId) => { const sid = await ensureSkillId(); await api.deleteSkillFrame(sid, frameId); setFrames((f) => f.filter((fr) => fr.id !== frameId)); }; const save = async () => { const sid = await ensureSkillId(); await api.updateSkill(sid, { name, type, level, tags }); navigate('#/skills'); }; return (
Skill
{!videoPath ? ( ) : ( )}
{frames.map((f) => (
))}
setName(e.target.value)} placeholder="Skill name" />
setType(e.target.value)} placeholder="e.g. Choreography, Vocal, Formation" />
setTags(e.target.value)} placeholder="comma, separated, tags" />
); } /* ------------------------------------------------------------------ */ /* App root */ /* ------------------------------------------------------------------ */ function App() { const route = useRoute(); let page; if (route.page === 'projectsList') page = ; else if (route.page === 'projectDetail') page = ; else if (route.page === 'trainingList') page = ; else if (route.page === 'trainingDetail') page = ; else if (route.page === 'skillsList') page = ; else if (route.page === 'skillForm') page = ; else page = ; return (
{page}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();