/* Journey admin page */ /* Journey Page */ function JourneyPage({ tweaks, onBackHome }) { const primary = tweaks.primaryColor; const [code, setCode] = React.useState(''); const [unlocked, setUnlocked] = React.useState(false); const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [saveState, setSaveState] = React.useState('saved'); const [error, setError] = React.useState(''); const [data, setData] = React.useState(getDefaultJourneyData); const [newProgressTitle, setNewProgressTitle] = React.useState(''); const [newProjectTitle, setNewProjectTitle] = React.useState(''); const [newSkillName, setNewSkillName] = React.useState(''); const [activeJourneyTab, setActiveJourneyTab] = React.useState('notes'); const fieldStyle = { width: '100%', padding: '12px 14px', borderRadius: 12, background: 'rgba(var(--theme-bg-rgb),0.5)', border: '1px solid rgba(var(--theme-text-rgb),0.12)', color: 'var(--theme-text)', fontFamily: 'Poppins, sans-serif', fontSize: 14, outline: 'none', }; const buttonStyle = (filled = true) => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 16px', borderRadius: 22, border: `1px solid ${filled ? primary : primary + '55'}`, background: filled ? primary : `${primary}12`, color: filled ? 'var(--theme-on-accent)' : primary, fontFamily: 'Poppins, sans-serif', fontWeight: 700, fontSize: 14, cursor: 'pointer', transition: 'all 0.25s ease', whiteSpace: 'nowrap', }); const iconButtonStyle = { width: 40, height: 40, borderRadius: 12, border: '1px solid rgba(var(--theme-text-rgb),0.1)', background: 'rgba(var(--theme-surface-rgb),0.35)', color: 'rgba(var(--theme-text-rgb),0.8)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: '0 0 auto', }; const deleteButtonStyle = { ...iconButtonStyle, border: '1px solid rgba(255,82,82,0.24)', background: 'rgba(255,82,82,0.1)', color: '#FF6B6B', }; const requestJourney = async (path, options = {}) => { const response = await fetch(`/api/journey${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}), }, }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.ok === false) { throw new Error(payload.error || 'Journey API error'); } return payload; }; const unlockJourney = async (candidateCode) => { const trimmedCode = candidateCode.trim(); setLoading(true); setError(''); try { const payload = await requestJourney('/verify', { method: 'POST', body: JSON.stringify({ code: trimmedCode }), }); sessionStorage.setItem(JOURNEY_CODE_STORAGE_KEY, trimmedCode); setCode(trimmedCode); setData(normalizeJourneyData(payload.data)); setUnlocked(true); setSaveState('saved'); } catch (err) { sessionStorage.removeItem(JOURNEY_CODE_STORAGE_KEY); setUnlocked(false); setError(err.message === 'Invalid code' ? 'Code is not correct.' : err.message); } finally { setLoading(false); } }; React.useEffect(() => { const savedCode = sessionStorage.getItem(JOURNEY_CODE_STORAGE_KEY); if (savedCode) unlockJourney(savedCode); }, []); const updateJourney = (updater) => { setData(prev => normalizeJourneyData( typeof updater === 'function' ? updater(prev) : { ...prev, ...updater } )); setSaveState('dirty'); setError(''); }; const saveJourney = async () => { setSaving(true); setError(''); try { const payload = await requestJourney('', { method: 'PUT', headers: { 'X-Journey-Code': code }, body: JSON.stringify({ data }), }); setData(normalizeJourneyData(payload.data)); setSaveState('saved'); } catch (err) { setError(err.message === 'Invalid code' ? 'Session code expired or changed.' : err.message); setSaveState('dirty'); } finally { setSaving(false); } }; const canLeaveJourney = () => saveState !== 'dirty' || window.confirm('Discard unsaved changes?'); const lockJourney = () => { if (!canLeaveJourney()) return; sessionStorage.removeItem(JOURNEY_CODE_STORAGE_KEY); setUnlocked(false); setCode(''); }; const backHome = () => { if (canLeaveJourney()) onBackHome(); }; const addProgressItem = (event) => { event.preventDefault(); const title = newProgressTitle.trim(); if (!title) return; updateJourney(prev => ({ ...prev, progress: [ ...prev.progress, { id: createJourneyId(), title, status: 'Active', progress: 0 }, ], })); setNewProgressTitle(''); }; const updateProgressItem = (id, patch) => { updateJourney(prev => ({ ...prev, progress: prev.progress.map(item => item.id === id ? { ...item, ...patch } : item), })); }; const removeProgressItem = (id) => { updateJourney(prev => ({ ...prev, progress: prev.progress.filter(item => item.id !== id), })); }; const addProjectItem = (event) => { event.preventDefault(); const title = newProjectTitle.trim(); if (!title) return; updateJourney(prev => ({ ...prev, projects: [ ...prev.projects, { id: createJourneyId(), title, summary: '', nextStep: '' }, ], })); setNewProjectTitle(''); }; const updateProjectItem = (id, patch) => { updateJourney(prev => ({ ...prev, projects: prev.projects.map(item => item.id === id ? { ...item, ...patch } : item), })); }; const removeProjectItem = (id) => { updateJourney(prev => ({ ...prev, projects: prev.projects.filter(item => item.id !== id), })); }; const addSkillItem = (event) => { event.preventDefault(); const name = newSkillName.trim(); if (!name) return; updateJourney(prev => ({ ...prev, skills: [ ...prev.skills, { id: createJourneyId(), name, level: 'Learning', progress: 0, focus: '' }, ], })); setNewSkillName(''); }; const updateSkillItem = (id, patch) => { updateJourney(prev => ({ ...prev, skills: prev.skills.map(item => item.id === id ? { ...item, ...patch } : item), })); }; const removeSkillItem = (id) => { updateJourney(prev => ({ ...prev, skills: prev.skills.filter(item => item.id !== id), })); }; const averageProgress = data.progress.length ? Math.round(data.progress.reduce((sum, item) => sum + clampProgress(item.progress), 0) / data.progress.length) : 0; const activeSkills = data.skills.filter(skill => skill.level !== 'Paused').length; const projectCount = data.projects.length; const journeyTabs = [ { id: 'notes', label: 'Notes', count: data.notes.trim() ? '1' : '0' }, { id: 'progress', label: 'Progress', count: data.progress.length }, { id: 'projects', label: 'Projects', count: projectCount }, { id: 'skills', label: 'Skills', count: data.skills.length }, ]; if (!unlocked) { return (
Private workspace

Journey

Enter the 32-character server code to open notes, progress, and learning skills.

{ event.preventDefault(); unlockJourney(code); }} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> setCode(event.target.value)} placeholder="32-character secret code" autoComplete="off" spellCheck="false" style={{ ...fieldStyle, fontSize: 16, letterSpacing: '1px' }} /> {error && (
{error}
)}
); } return (
Personal journey

Journey

{saveState === 'dirty' ? 'Unsaved changes' : data.updatedAt ? `Last saved ${new Date(data.updatedAt).toLocaleString()}` : 'Not saved yet'}
{error && (
{error}
)}
{journeyTabs.map(tab => ( setActiveJourneyTab(tab.id)} /> ))}
{activeJourneyTab === 'notes' && (