Journey
Enter the 32-character server code to open notes, progress, and learning skills.
/* 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 (
Enter the 32-character server code to open notes, progress, and learning skills.
Journey
Journey
{title}