// To Do page — renders the bulleted list in webpage/data/todo.md. // // The whole file is wrapped in an IIFE so its helpers stay out of the shared // global scope that every text/babel script compiles into (see the "no // top-level const" gotcha in docs/design_style_guide.md). (function () { 'use strict'; const { Box } = MaterialUI; // IIFE scope, so the module-level helpers below can reach the design system // too (the component re-reads it for readability at its own top). const DS = window.DesignSystem; const TODO_URL = '/data/todo.md'; // `- item`, `* item`, `+ item`, `1. item`, `1) item` const LIST_RE = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; // Optional task-list checkbox at the start of an item: `- [ ] item` const CHECKBOX_RE = /^\[([ xX])\]\s+(.*)$/; const HEADING_RE = /^(#{1,6})\s+(.*)$/; // --- markdown -> section/item tree --------------------------------------- // Only what a to-do list needs: headings become sections, list items nest by // indentation, and anything else becomes a note line under the section. const parseTodoMarkdown = (text) => { const lines = text.replace(/\r\n?/g, '\n').split('\n'); let docTitle = null; const sections = []; let section = null; // Stack of { indent, items } — `items` is the list the next item at that // depth gets pushed onto. let stack = []; const ensureSection = () => { if (!section) { section = { title: null, items: [], notes: [] }; sections.push(section); } return section; }; lines.forEach((rawLine) => { const line = rawLine.replace(/\s+$/, ''); if (line.trim() === '') { return; } const heading = line.match(HEADING_RE); if (heading) { const level = heading[1].length; const title = heading[2].trim(); if (level === 1 && docTitle === null && sections.length === 0) { docTitle = title; } else { section = { title: title, items: [], notes: [] }; sections.push(section); } stack = []; return; } const listItem = line.match(LIST_RE); if (listItem) { const indent = listItem[1].replace(/\t/g, ' ').length; const marker = listItem[2]; const ordered = /\d/.test(marker); let content = listItem[3].trim(); let done = null; const checkbox = content.match(CHECKBOX_RE); if (checkbox) { done = checkbox[1].toLowerCase() === 'x'; content = checkbox[2].trim(); } const item = { text: content, ordered: ordered, number: ordered ? parseInt(marker, 10) : null, done: done, children: [] }; const target = ensureSection(); // Pop back to the level this item belongs at, then either nest under // the last item (deeper indent) or sit alongside it. while (stack.length > 0 && indent < stack[stack.length - 1].indent) { stack.pop(); } if (stack.length === 0) { stack = [{ indent: indent, items: target.items }]; } else if (indent > stack[stack.length - 1].indent) { const parentItems = stack[stack.length - 1].items; const parent = parentItems[parentItems.length - 1]; if (parent) { stack.push({ indent: indent, items: parent.children }); } } stack[stack.length - 1].items.push(item); return; } // A non-list, indented line continues the previous item's text; // otherwise it's a standalone note under the current section. const target = ensureSection(); const currentItems = stack.length > 0 ? stack[stack.length - 1].items : null; const last = currentItems && currentItems[currentItems.length - 1]; if (last && /^\s/.test(rawLine)) { last.text += ' ' + line.trim(); } else { target.notes.push(line.trim()); stack = []; } }); return { title: docTitle, sections: sections }; }; // --- inline formatting ---------------------------------------------------- // `code`, **bold**, *italic*, [text](href) — rendered as React nodes so no // markdown source leaks into the page and no HTML is injected. const renderInline = (text, T, keyPrefix) => { const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)\s]+\))/g; const nodes = []; let lastIndex = 0; let match; let i = 0; while ((match = pattern.exec(text)) !== null) { if (match.index > lastIndex) { nodes.push(text.slice(lastIndex, match.index)); } const token = match[0]; const key = keyPrefix + '-' + i++; if (token.startsWith('`')) { nodes.push( {token.slice(1, -1)} ); } else if (token.startsWith('**')) { nodes.push({token.slice(2, -2)}); } else if (token.startsWith('*')) { nodes.push({token.slice(1, -1)}); } else { const split = token.indexOf(']('); nodes.push( {token.slice(1, split)} ); } lastIndex = match.index + token.length; } if (lastIndex < text.length) { nodes.push(text.slice(lastIndex)); } return nodes; }; // --- list rendering ------------------------------------------------------- // Depth 0 markers are accent squares (or the item's number); deeper tiers // step down to a hairline outline square, mirroring the design-system rules. const Marker = ({ T, item, depth }) => { if (item.done !== null) { return ( {item.done ? '✓' : ''} ); } if (item.ordered) { return ( {item.number}. ); } return ( ); }; const TodoList = ({ T, items, depth, keyPrefix }) => ( {items.map((item, index) => { const key = keyPrefix + '-' + index; return ( {renderInline(item.text, T, key)} {item.children.length > 0 && ( )} ); })} ); const TodoPage = () => { const { CircularProgress, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Snackbar, Alert } = MaterialUI; const theme = MaterialUI.useTheme(); const darkMode = theme.palette.mode === 'dark'; const DS = window.DesignSystem; const T = DS.tokens(darkMode); const [state, setState] = React.useState({ status: 'loading', doc: null, source: '', error: null }); // Unlock state mirrors the Plants page: the password itself doubles as the // "unlocked" flag, lives only in memory, and is re-sent with every write. const [unlockPassword, setUnlockPassword] = React.useState(''); const [unlockDialogOpen, setUnlockDialogOpen] = React.useState(false); const [unlockInput, setUnlockInput] = React.useState(''); const [unlockError, setUnlockError] = React.useState(false); const [snackbar, setSnackbar] = React.useState({ open: false, message: '', severity: 'success' }); const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(''); const [saving, setSaving] = React.useState(false); React.useEffect(() => { let cancelled = false; fetch(TODO_URL, { cache: 'no-store' }) .then((response) => { if (!response.ok) { throw new Error(`Could not load ${TODO_URL} (HTTP ${response.status})`); } return response.text(); }) .then((text) => { if (!cancelled) { setState({ status: 'ready', doc: parseTodoMarkdown(text), source: text, error: null }); } }) .catch((error) => { if (!cancelled) { setState({ status: 'error', doc: null, source: '', error: error.message }); } }); return () => { cancelled = true; }; }, []); const handleUnlock = async () => { try { const response = await fetch('/data/todo/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: unlockInput }) }); const data = await response.json(); if (data.unlocked) { setUnlockPassword(unlockInput); setUnlockDialogOpen(false); setUnlockInput(''); setUnlockError(false); setSnackbar({ open: true, message: 'To Do editing unlocked', severity: 'success' }); } else { setUnlockError(true); } } catch (error) { setSnackbar({ open: true, message: `Unlock failed: ${error.message}`, severity: 'error' }); } }; const handleSave = async () => { setSaving(true); try { const response = await fetch('/data/todo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: draft, password: unlockPassword }) }); if (!response.ok) { const message = await response.text(); if (response.status === 403) { setUnlockPassword(''); setEditing(false); } setSnackbar({ open: true, message: message || `Save failed (HTTP ${response.status})`, severity: 'error' }); return; } setState({ status: 'ready', doc: parseTodoMarkdown(draft), source: draft, error: null }); setEditing(false); setSnackbar({ open: true, message: 'To Do saved', severity: 'success' }); } catch (error) { setSnackbar({ open: true, message: `Save failed: ${error.message}`, severity: 'error' }); } finally { setSaving(false); } }; const doc = state.doc; const modBtnSx = DS.secondaryBtnSx(T); return ( {state.status === 'loading' && ( )} {!editing && state.status === 'error' && ( Error {state.error} Add a bulleted markdown list at webpage/data/todo.md to populate this page. )} {editing && ( Editing todo.md setDraft(event.target.value)} sx={{ display: 'block', width: '100%', marginTop: '10px', minHeight: '480px', padding: '12px', resize: 'vertical', boxSizing: 'border-box', fontFamily: DS.MONO, fontSize: '13px', lineHeight: 1.6, color: T.text, backgroundColor: T.surface, border: DS.hairline(T), borderRadius: 0, outline: 'none', '&:focus': { borderColor: T.accent } }} /> )} {!editing && state.status === 'ready' && doc.sections.length === 0 && ( todo.md is empty — nothing to do. )} {!editing && state.status === 'ready' && doc.sections.length > 0 && ( {doc.sections.map((section, index) => ( {section.title && ( {section.title} )} {section.notes.map((note, noteIndex) => ( {renderInline(note, T, 'note-' + index + '-' + noteIndex)} ))} {section.items.length > 0 && ( )} ))} )} {/* Footer: editing controls — sits on the ground with a 2px top rule */} {state.status !== 'loading' && ( {unlockPassword ? 'List editing · unlocked' : 'List editing · locked'} {!unlockPassword && ( )} {unlockPassword && !editing && ( )} {unlockPassword && editing && ( )} )} {/* Unlock dialog */} setUnlockDialogOpen(false)} maxWidth="xs" fullWidth> Unlock To Do Editing { setUnlockInput(e.target.value); setUnlockError(false); }} onKeyDown={(e) => { if (e.key === 'Enter') handleUnlock(); }} error={unlockError} helperText={unlockError ? 'Incorrect password' : ''} /> {/* Snackbar for success/error feedback */} setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity} sx={{ width: '100%' }} > {snackbar.message} ); }; window.TodoPage = TodoPage; })();