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
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 ? (
) : (
No video saved for "{skill.name}" yet.
)}
);
}
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
);
}
/* ------------------------------------------------------------------ */
/* 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();