import * as api from './lib/api.mjs'; import { qs, clone, set_text, show, hide } from './lib/dom.mjs'; import { EditorState, EditorView, keymap, placeholder, drawSelection, defaultKeymap, history as cm_history, historyKeymap, indentWithTab, addCursorAbove, addCursorBelow, markdown, syntaxHighlighting, oneDark, } from '/vendor/codemirror-bundle.mjs'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- class App_State { constructor() { this.entry_types = []; this.entries = []; this.active_type = null; // null = all types this.active_view = 'entries'; // 'entries' | 'entry' | 'edit' | 'manage-types' this.active_entry_id = null; this.active_edit = null; // { mode: 'new'|'edit', id?, type?, parent_id? } this.filter_status = 'open'; this.filter_priority = ''; this.filter_tag = ''; this.search = ''; this.flat_mode = false; this.expanded = new Set(); } } const state = new App_State(); // --------------------------------------------------------------------------- // Markdown // --------------------------------------------------------------------------- const md_cache = new Map(); async function render_markdown(text) { if (md_cache.has(text)) { return md_cache.get(text); } const { html } = await api.render_markdown(text); md_cache.set(text, html); return html; } function fill_markdown(el, text) { if (!text) { el.innerHTML = ''; return; } const cached = md_cache.get(text); if (cached !== undefined) { el.innerHTML = cached; return; } el.textContent = ''; render_markdown(text).then(html => { el.innerHTML = html; }); } // --------------------------------------------------------------------------- // Formatting // --------------------------------------------------------------------------- function format_time(ms) { const diff = Date.now() - ms; const m = Math.floor(diff / 60000); const h = Math.floor(diff / 3600000); const d = Math.floor(diff / 86400000); if (m < 1) { return 'just now'; } if (m < 60) { return `${m}m ago`; } if (h < 24) { return `${h}h ago`; } if (d < 7) { return `${d}d ago`; } return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d > 365 ? 'numeric' : undefined }); } // --------------------------------------------------------------------------- // Tree helpers // --------------------------------------------------------------------------- function build_children_map() { const map = new Map(); for (const entry of state.entries) { const pid = entry.parent_id ?? null; if (!map.has(pid)) { map.set(pid, []); } map.get(pid).push(entry); } return map; } function get_descendants(entry_id) { const result = new Set(); const queue = state.entries.filter(e => e.parent_id === entry_id); while (queue.length) { const e = queue.shift(); result.add(e.id); for (const child of state.entries.filter(c => c.parent_id === e.id)) { queue.push(child); } } return result; } function entry_matches_filter(entry) { if (state.active_type && entry.type !== state.active_type) { return false; } if (state.filter_status && entry.status !== state.filter_status) { return false; } if (state.filter_priority && entry.priority !== state.filter_priority) { return false; } if (state.filter_tag && !entry.tags.includes(state.filter_tag)) { return false; } if (state.search) { const q = state.search.toLowerCase(); if (!entry.title.toLowerCase().includes(q) && !entry.body.toLowerCase().includes(q)) { return false; } } return true; } function node_or_descendants_match(entry, children_map) { if (entry_matches_filter(entry)) { return true; } return (children_map.get(entry.id) ?? []).some(c => node_or_descendants_match(c, children_map)); } function all_tags() { const tags = new Set(); for (const e of state.entries) { for (const tag of e.tags) { tags.add(tag); } } return [...tags].sort(); } // --------------------------------------------------------------------------- // Entry picker dialog // --------------------------------------------------------------------------- class Entry_Picker_Dialog { constructor() { const el = clone('t-entry-picker'); document.body.appendChild(el); this.dialog = el; this.on_pick = null; this._available = []; const search = el.querySelector('.picker-search'); search.addEventListener('input', () => this._render_list(search.value)); el.querySelector('.btn-cancel').addEventListener('click', () => el.close()); } _render_list(query) { const list = this.dialog.querySelector('.picker-list'); list.innerHTML = ''; const q = query.toLowerCase(); const filtered = q ? this._available.filter(e => e.title.toLowerCase().includes(q) || String(e.id).includes(q)) : this._available; if (!filtered.length) { list.textContent = 'No entries.'; return; } for (const entry of filtered) { const item = document.createElement('div'); item.className = 'picker-item'; item.textContent = `#${entry.id} [${entry.type}] ${entry.title}`; item.addEventListener('click', () => { if (this.on_pick) { this.on_pick(entry.id); } this.dialog.close(); }); list.appendChild(item); } } open(excluded_ids, on_pick) { this._available = state.entries.filter(e => !excluded_ids.has(e.id)); this.on_pick = on_pick; const search = this.dialog.querySelector('.picker-search'); search.value = ''; this._render_list(''); this.dialog.showModal(); } } let entry_picker; // --------------------------------------------------------------------------- // Entry type dialog // --------------------------------------------------------------------------- class Entry_Type_Dialog { constructor() { const el = clone('t-entry-type-dialog'); document.body.appendChild(el); this.dialog = el; this.form = el.querySelector('form'); this.on_save = null; el.querySelector('.btn-cancel').addEventListener('click', () => el.close()); this.form.addEventListener('submit', (e) => { e.preventDefault(); if (this.on_save) { this.on_save(this._read_form()); } el.close(); }); } _read_form() { const fd = new FormData(this.form); return { id: fd.get('id')?.trim(), title: fd.get('title').trim(), description: fd.get('description').trim(), }; } open(initial, on_save) { const is_new = !initial?.id; set_text(this.dialog, '.dialog-title', is_new ? 'New Entry Type' : 'Edit Entry Type'); const id_label = this.dialog.querySelector('.id-label'); const id_display = this.dialog.querySelector('.id-display-row'); if (is_new) { show(id_label); hide(id_display); this.form.elements['id'].value = ''; this.form.elements['id'].required = true; } else { hide(id_label); this.dialog.querySelector('.id-display-value').textContent = initial.id; show(id_display); this.form.elements['id'].required = false; } this.form.elements['title'].value = initial?.title ?? ''; this.form.elements['description'].value = initial?.description ?? ''; this.on_save = on_save; this.dialog.showModal(); } } let entry_type_dialog; // --------------------------------------------------------------------------- // Render helpers // --------------------------------------------------------------------------- function make_entry_row(entry, children_map) { const children = children_map.get(entry.id) ?? []; const row = clone('t-entry-row'); row.dataset.priority = entry.priority; row.dataset.status = entry.status; // Expand button — invisible in flat mode or when no children const expand_btn = row.querySelector('.btn-expand'); if (!state.flat_mode && children.length > 0) { expand_btn.style.visibility = 'visible'; expand_btn.textContent = state.expanded.has(entry.id) ? '▼' : '▶'; expand_btn.addEventListener('click', () => { if (state.expanded.has(entry.id)) { state.expanded.delete(entry.id); } else { state.expanded.add(entry.id); } render(); }); } set_text(row, '.task-id', `#${entry.id}`); // Type chip — always shown, clickable to navigate to that type const type_chip = row.querySelector('.entry-type-chip'); const et = state.entry_types.find(t => t.id === entry.type); type_chip.textContent = et ? et.title : entry.type; type_chip.classList.add('clickable'); type_chip.addEventListener('click', (e) => { e.stopPropagation(); navigate('type/' + entry.type); }); show(type_chip); const status_el = row.querySelector('.task-status'); status_el.textContent = entry.status; status_el.dataset.val = entry.status; status_el.classList.add('clickable'); status_el.addEventListener('click', (e) => { e.stopPropagation(); set_filter('filter_status', entry.status); }); const priority_el = row.querySelector('.task-priority'); priority_el.textContent = entry.priority; priority_el.dataset.val = entry.priority; priority_el.classList.add('clickable'); priority_el.addEventListener('click', (e) => { e.stopPropagation(); set_filter('filter_priority', entry.priority); }); const main_el = row.querySelector('.task-main'); main_el.classList.add('clickable'); main_el.addEventListener('click', () => navigate(String(entry.id))); fill_markdown(row.querySelector('.task-title'), entry.title); const tags_el = row.querySelector('.task-tags'); for (const tag of entry.tags) { const span = document.createElement('span'); span.className = 'tag clickable-tag'; span.textContent = tag; span.title = `Filter by tag: ${tag}`; span.addEventListener('click', (e) => { e.stopPropagation(); const base = state.active_type ? 'type/' + state.active_type : ''; navigate(base ? base + '/tag/' + tag : 'tag/' + tag); }); tags_el.appendChild(span); } const time_el = row.querySelector('.task-time'); time_el.textContent = format_time(entry.created_at); time_el.title = new Date(entry.created_at).toLocaleString(); row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(entry.id, null)); row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(entry)); row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(entry)); return row; } function render_tree_node(entry, children_map) { const children = children_map.get(entry.id) ?? []; const node = document.createElement('div'); node.className = 'task-node'; node.appendChild(make_entry_row(entry, children_map)); if (children.length > 0 && state.expanded.has(entry.id)) { const children_el = document.createElement('div'); children_el.className = 'task-children'; for (const child of children) { children_el.appendChild(render_tree_node(child, children_map)); } node.appendChild(children_el); } return node; } // --------------------------------------------------------------------------- // Nav // --------------------------------------------------------------------------- function render_nav() { const nav = document.getElementById('main-nav'); nav.innerHTML = ''; const all_btn = document.createElement('button'); all_btn.className = 'nav-btn' + (state.active_view === 'entries' && state.active_type === null ? ' active' : ''); all_btn.textContent = 'All'; all_btn.addEventListener('click', () => navigate('')); nav.appendChild(all_btn); for (const et of state.entry_types) { const btn = document.createElement('button'); btn.className = 'nav-btn' + (state.active_view === 'entries' && state.active_type === et.id ? ' active' : ''); btn.textContent = et.title; btn.addEventListener('click', () => navigate('type/' + et.id)); nav.appendChild(btn); } const manage_btn = document.createElement('button'); manage_btn.className = 'nav-btn' + (state.active_view === 'manage-types' ? ' active' : ''); manage_btn.textContent = 'Manage Types'; manage_btn.addEventListener('click', () => navigate('manage-types')); nav.appendChild(manage_btn); } // --------------------------------------------------------------------------- // Entries view // --------------------------------------------------------------------------- function render_entries(container) { container.innerHTML = ''; const children_map = build_children_map(); const roots = children_map.get(null) ?? []; // Toolbar const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; const h2 = document.createElement('h2'); if (state.active_type) { const et = state.entry_types.find(t => t.id === state.active_type); h2.textContent = et ? et.title + 's' : state.active_type; } else { h2.textContent = 'All Entries'; } toolbar.appendChild(h2); const mode_toggle = document.createElement('div'); mode_toggle.className = 'segmented-control'; for (const [val, label] of [[false, 'Tree'], [true, 'Flat']]) { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = label; btn.classList.toggle('active', state.flat_mode === val); btn.addEventListener('click', () => { state.flat_mode = val; render(); }); mode_toggle.appendChild(btn); } toolbar.appendChild(mode_toggle); const et_active = state.entry_types.find(t => t.id === state.active_type); const add_btn = document.createElement('button'); add_btn.className = 'btn-primary'; add_btn.textContent = et_active ? `+ New ${et_active.title.toLowerCase()}` : '+ New entry'; add_btn.addEventListener('click', () => open_add_dialog(null, state.active_type)); toolbar.appendChild(add_btn); container.appendChild(toolbar); // Filter bar const filter_bar = document.createElement('div'); filter_bar.className = 'filter-bar'; const status_sel = document.createElement('select'); for (const [val, label] of [['', 'All statuses'], ['open', 'Open'], ['deferred', 'Deferred'], ['done', 'Done'], ['cancelled', 'Cancelled']]) { const opt = document.createElement('option'); opt.value = val; opt.textContent = label; if (val === state.filter_status) { opt.selected = true; } status_sel.appendChild(opt); } status_sel.addEventListener('change', () => { set_filter('filter_status', status_sel.value); }); filter_bar.appendChild(status_sel); const priority_sel = document.createElement('select'); for (const [val, label] of [['', 'All priorities'], ['high', 'High'], ['normal', 'Normal'], ['low', 'Low']]) { const opt = document.createElement('option'); opt.value = val; opt.textContent = label; if (val === state.filter_priority) { opt.selected = true; } priority_sel.appendChild(opt); } priority_sel.addEventListener('change', () => { set_filter('filter_priority', priority_sel.value); }); filter_bar.appendChild(priority_sel); const tag_sel = document.createElement('select'); const all_opt = document.createElement('option'); all_opt.value = ''; all_opt.textContent = 'All tags'; tag_sel.appendChild(all_opt); for (const tag of all_tags()) { const opt = document.createElement('option'); opt.value = tag; opt.textContent = tag; if (tag === state.filter_tag) { opt.selected = true; } tag_sel.appendChild(opt); } tag_sel.addEventListener('change', () => { const base = state.active_type ? 'type/' + state.active_type : ''; const tag_hash = tag_sel.value ? (base ? base + '/tag/' + tag_sel.value : 'tag/' + tag_sel.value) : base; navigate(tag_hash); }); filter_bar.appendChild(tag_sel); const search_input = document.createElement('input'); search_input.type = 'text'; search_input.placeholder = 'Search…'; search_input.value = state.search; search_input.addEventListener('input', () => { set_filter('search', search_input.value); }); filter_bar.appendChild(search_input); container.appendChild(filter_bar); // Entry list const list = document.createElement('div'); list.className = 'task-list' + (state.flat_mode ? ' flat-mode' : ''); if (state.flat_mode) { const flat = state.entries.filter(e => entry_matches_filter(e)); if (!flat.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; empty.textContent = 'No entries.'; list.appendChild(empty); } for (const entry of flat) { list.appendChild(make_entry_row(entry, children_map)); } } else { const visible = roots.filter(e => entry_matches_filter(e)); if (!visible.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; empty.textContent = 'No entries.'; list.appendChild(empty); } for (const root of visible) { const node = render_tree_node(root, children_map); if (node) { list.appendChild(node); } } } container.appendChild(list); } // --------------------------------------------------------------------------- // Manage types view // --------------------------------------------------------------------------- function render_manage_types(container) { container.innerHTML = ''; const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; const h2 = document.createElement('h2'); h2.textContent = 'Entry Types'; toolbar.appendChild(h2); const add_btn = document.createElement('button'); add_btn.className = 'btn-primary'; add_btn.textContent = '+ New Type'; add_btn.addEventListener('click', () => { entry_type_dialog.open(null, async (data) => { try { const { entry_type } = await api.create_entry_type(data); state.entry_types.push(entry_type); render(); } catch (err) { alert(err.message); } }); }); toolbar.appendChild(add_btn); container.appendChild(toolbar); if (!state.entry_types.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; empty.textContent = 'No entry types defined.'; container.appendChild(empty); return; } const list = document.createElement('div'); list.className = 'type-list'; for (const et of state.entry_types) { const row = document.createElement('div'); row.className = 'type-row'; const info = document.createElement('div'); info.className = 'type-info'; const id_span = document.createElement('code'); id_span.className = 'type-id'; id_span.textContent = et.id; info.appendChild(id_span); const title_span = document.createElement('span'); title_span.className = 'type-title'; title_span.textContent = et.title; info.appendChild(title_span); if (et.description) { const desc = document.createElement('span'); desc.className = 'type-desc'; desc.textContent = et.description; info.appendChild(desc); } row.appendChild(info); const actions = document.createElement('div'); actions.className = 'task-actions'; const edit_btn = document.createElement('button'); edit_btn.className = 'btn-edit'; edit_btn.textContent = 'Edit'; edit_btn.addEventListener('click', () => { entry_type_dialog.open(et, async (data) => { try { const { entry_type } = await api.update_entry_type(et.id, data); const idx = state.entry_types.findIndex(t => t.id === et.id); if (idx !== -1) { state.entry_types[idx] = entry_type; } render(); } catch (err) { alert(err.message); } }); }); actions.appendChild(edit_btn); const del_btn = document.createElement('button'); del_btn.className = 'btn-delete'; del_btn.textContent = 'Delete'; del_btn.addEventListener('click', async () => { if (!confirm(`Delete entry type "${et.title}" (id: ${et.id})?`)) { return; } try { await api.delete_entry_type(et.id); state.entry_types = state.entry_types.filter(t => t.id !== et.id); if (state.active_type === et.id) { state.active_type = null; } render(); } catch (err) { alert(err.message); } }); actions.appendChild(del_btn); row.appendChild(actions); list.appendChild(row); } container.appendChild(list); } // --------------------------------------------------------------------------- // Routing // --------------------------------------------------------------------------- function apply_hash(hash) { if (hash === 'manage-types') { state.active_view = 'manage-types'; state.active_type = null; state.active_entry_id = null; state.active_edit = null; } else if (hash === '' || hash.startsWith('type/') || hash.startsWith('tag/')) { state.active_view = 'entries'; state.active_entry_id = null; state.active_edit = null; if (hash.startsWith('type/')) { const rest = hash.slice(5); const tag_idx = rest.indexOf('/tag/'); if (tag_idx !== -1) { state.active_type = rest.slice(0, tag_idx); state.filter_tag = rest.slice(tag_idx + 5); } else { state.active_type = rest; state.filter_tag = ''; } } else if (hash.startsWith('tag/')) { state.active_type = null; state.filter_tag = hash.slice(4); } else { state.active_type = null; state.filter_tag = ''; } } else if (/^\d+$/.test(hash)) { state.active_view = 'entry'; state.active_entry_id = Number(hash); state.active_edit = null; } else if (hash.startsWith('edit/')) { state.active_view = 'edit'; state.active_edit = { mode: 'edit', id: Number(hash.slice(5)) }; } else if (hash === 'new' || hash.startsWith('new/')) { state.active_view = 'edit'; const rest = hash.length > 4 ? hash.slice(4) : ''; const [type_part = '', parent_part = ''] = rest.split('/'); state.active_edit = { mode: 'new', type: (type_part && type_part !== '_') ? type_part : null, parent_id: parent_part ? Number(parent_part) : null, }; } else { state.active_view = 'entries'; state.active_type = null; state.active_entry_id = null; state.active_edit = null; } } function navigate_new(type, parent_id) { let hash = 'new'; if (type || parent_id) { hash += '/' + (type || '_'); } if (parent_id) { hash += '/' + parent_id; } navigate(hash); } function build_url(hash) { const params = new URLSearchParams(); params.set('status', state.filter_status || 'all'); if (state.filter_priority) { params.set('priority', state.filter_priority); } if (state.search) { params.set('search', state.search); } return '?' + params.toString() + '#' + hash; } function apply_search() { const params = new URLSearchParams(location.search); const s = params.get('status'); state.filter_status = (s === null || s === '') ? 'open' : (s === 'all' ? '' : s); state.filter_priority = params.get('priority') ?? ''; state.search = params.get('search') ?? ''; } function navigate(hash) { history.pushState(null, '', build_url(hash)); apply_hash(hash); render(); } function set_filter(key, val) { state[key] = val; history.replaceState(null, '', build_url(location.hash.slice(1))); render(); } window.addEventListener('popstate', () => { apply_hash(location.hash.slice(1)); apply_search(); render(); }); // --------------------------------------------------------------------------- // Entry detail view // --------------------------------------------------------------------------- function render_entry_detail(container) { container.innerHTML = ''; const entry = state.entries.find(e => e.id === state.active_entry_id); if (!entry) { const msg = document.createElement('div'); msg.className = 'empty-state'; msg.textContent = `Entry #${state.active_entry_id} not found.`; container.appendChild(msg); return; } const et = state.entry_types.find(t => t.id === entry.type); const back_hash = 'type/' + entry.type; const sentinel = document.createElement('div'); const on_keydown = e => { if (e.ctrlKey && e.key === 'e') { e.preventDefault(); navigate('edit/' + entry.id); } }; document.addEventListener('keydown', on_keydown); const observer = new MutationObserver(() => { if (!sentinel.isConnected) { document.removeEventListener('keydown', on_keydown); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); container.appendChild(sentinel); // Header const header = document.createElement('div'); header.className = 'detail-header'; const back_btn = document.createElement('a'); back_btn.className = 'detail-back'; back_btn.href = '#' + back_hash; back_btn.textContent = '← ' + (et ? et.title + 's' : entry.type); back_btn.addEventListener('click', (e) => { e.preventDefault(); navigate(back_hash); }); header.appendChild(back_btn); const id_span = document.createElement('span'); id_span.className = 'detail-id'; id_span.textContent = `#${entry.id}`; header.appendChild(id_span); const actions = document.createElement('div'); actions.className = 'detail-actions'; const edit_btn = document.createElement('button'); edit_btn.textContent = 'Edit'; edit_btn.addEventListener('click', () => open_edit_dialog(entry)); const del_btn = document.createElement('button'); del_btn.className = 'btn-danger'; del_btn.textContent = 'Delete'; del_btn.addEventListener('click', () => confirm_delete(entry)); const sub_btn = document.createElement('button'); sub_btn.textContent = '+ Sub'; sub_btn.addEventListener('click', () => open_add_dialog(entry.id, entry.type)); actions.appendChild(sub_btn); actions.appendChild(edit_btn); actions.appendChild(del_btn); header.appendChild(actions); container.appendChild(header); // Title const title_el = document.createElement('div'); title_el.className = 'detail-title markup'; fill_markdown(title_el, entry.title); container.appendChild(title_el); // Meta const meta = document.createElement('div'); meta.className = 'detail-meta'; if (et) { const type_chip = document.createElement('span'); type_chip.className = 'entry-type-chip'; type_chip.textContent = et.title; type_chip.addEventListener('click', () => navigate('type/' + entry.type)); meta.appendChild(type_chip); } const status_el = document.createElement('span'); status_el.className = 'task-status clickable'; status_el.textContent = entry.status; status_el.dataset.val = entry.status; status_el.addEventListener('click', () => { state.filter_status = entry.status; navigate('type/' + entry.type); }); meta.appendChild(status_el); const priority_el = document.createElement('span'); priority_el.className = 'task-priority clickable'; priority_el.textContent = entry.priority; priority_el.dataset.val = entry.priority; priority_el.addEventListener('click', () => { state.filter_priority = entry.priority; navigate('type/' + entry.type); }); meta.appendChild(priority_el); for (const tag of entry.tags) { const span = document.createElement('span'); span.className = 'tag clickable-tag'; span.textContent = tag; span.title = `Filter by tag: ${tag}`; span.addEventListener('click', () => { const base = entry.type ? 'type/' + entry.type : ''; navigate(base ? base + '/tag/' + tag : 'tag/' + tag); }); meta.appendChild(span); } if (entry.parent_id) { const parent = state.entries.find(e => e.id === entry.parent_id); const parent_link = document.createElement('a'); parent_link.className = 'detail-parent-link'; parent_link.href = '#' + entry.parent_id; parent_link.textContent = parent ? `↑ #${parent.id} ${parent.title}` : `↑ #${entry.parent_id}`; parent_link.addEventListener('click', (e) => { e.preventDefault(); navigate(String(entry.parent_id)); }); meta.appendChild(parent_link); } container.appendChild(meta); // Body if (entry.body) { const body_el = document.createElement('div'); body_el.className = 'detail-body markup'; fill_markdown(body_el, entry.body); container.appendChild(body_el); } // Children const children = state.entries.filter(e => e.parent_id === entry.id); if (children.length) { const section = document.createElement('div'); section.className = 'detail-children'; const heading = document.createElement('h3'); heading.textContent = `Children (${children.length})`; section.appendChild(heading); const children_map = build_children_map(); for (const child of children) { const row = make_entry_row(child, children_map); section.appendChild(row); } container.appendChild(section); } } // --------------------------------------------------------------------------- // Edit view // --------------------------------------------------------------------------- function render_edit_view(container) { container.innerHTML = ''; const ae = state.active_edit; const entry = ae.mode === 'edit' ? state.entries.find(e => e.id === ae.id) : null; if (ae.mode === 'edit' && !entry) { container.textContent = `Entry #${ae.id} not found.`; return; } const back_hash = ae.mode === 'edit' ? String(ae.id) : (ae.type ? 'type/' + ae.type : ''); let current_parent_id = ae.parent_id ?? entry?.parent_id ?? null; // View wrapper const view = document.createElement('div'); view.className = 'edit-view'; // --- Header --- const header = document.createElement('div'); header.className = 'edit-header'; const back_btn = document.createElement('a'); back_btn.className = 'detail-back'; back_btn.href = '#' + back_hash; back_btn.textContent = '← Back'; back_btn.addEventListener('click', e => { e.preventDefault(); navigate(back_hash); }); header.appendChild(back_btn); const title_input = document.createElement('input'); title_input.type = 'text'; title_input.className = 'edit-title-input'; title_input.placeholder = 'Title'; title_input.value = entry?.title ?? ''; header.appendChild(title_input); const save_btn = document.createElement('button'); save_btn.className = 'btn-primary'; save_btn.textContent = 'Save'; const cancel_btn = document.createElement('button'); cancel_btn.textContent = 'Cancel'; cancel_btn.addEventListener('click', () => navigate(back_hash)); header.appendChild(save_btn); header.appendChild(cancel_btn); view.appendChild(header); // --- Body area: (tabs + textarea/preview) | sidebar --- const body_area = document.createElement('div'); body_area.className = 'edit-body-area'; // Tab bar const tab_bar = document.createElement('div'); tab_bar.className = 'edit-tab-bar'; const write_tab = document.createElement('button'); write_tab.type = 'button'; write_tab.textContent = 'Write'; write_tab.className = 'edit-tab active'; const preview_tab = document.createElement('button'); preview_tab.type = 'button'; preview_tab.textContent = 'Preview'; preview_tab.className = 'edit-tab'; tab_bar.appendChild(write_tab); tab_bar.appendChild(preview_tab); const tabs = [write_tab, preview_tab]; function switch_tab(dir) { const current = tabs.findIndex(t => t.classList.contains('active')); const next = (current + dir + tabs.length) % tabs.length; tabs[next].click(); } // CodeMirror editor const cm_host = document.createElement('div'); cm_host.className = 'edit-cm-host'; const cm_editor = new EditorView({ state: EditorState.create({ doc: entry?.body ?? '', extensions: [ cm_history(), drawSelection(), EditorState.allowMultipleSelections.of(true), EditorView.clickAddsSelectionRange.of(e => e.altKey), keymap.of([ { key: 'Shift-Alt-ArrowUp', run: addCursorAbove }, { key: 'Shift-Alt-ArrowDown', run: addCursorBelow }, ...defaultKeymap, ...historyKeymap, indentWithTab, ]), EditorView.lineWrapping, placeholder('Body (markdown)'), markdown(), oneDark, EditorView.theme({ '&': { height: '100%' }, '.cm-editor.cm-focused': { outline: 'none' }, '.cm-gutters': { display: 'none' }, '.cm-placeholder': { fontStyle: 'italic' }, }), ], }), parent: cm_host, }); function get_editor_value() { return cm_editor.state.doc.toString(); } // Preview pane const preview_el = document.createElement('div'); preview_el.className = 'edit-preview markup'; preview_el.hidden = true; write_tab.addEventListener('click', () => { write_tab.classList.add('active'); preview_tab.classList.remove('active'); cm_host.hidden = false; preview_el.hidden = true; cm_editor.focus(); }); preview_tab.addEventListener('click', () => { preview_tab.classList.add('active'); write_tab.classList.remove('active'); cm_host.hidden = true; preview_el.hidden = false; const text = get_editor_value(); if (!text.trim()) { preview_el.innerHTML = 'Nothing to preview.'; } else { preview_el.textContent = '…'; render_markdown(text).then(html => { preview_el.innerHTML = html; }); } }); // Ctrl+S on title input const on_edit_keydown = e => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); } if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); cancel_btn.click(); } if (e.ctrlKey && e.altKey && e.key === 'PageUp') { e.preventDefault(); switch_tab(-1); } if (e.ctrlKey && e.altKey && e.key === 'PageDown') { e.preventDefault(); switch_tab(+1); } }; document.addEventListener('keydown', on_edit_keydown); const edit_observer = new MutationObserver(() => { if (!view.isConnected) { document.removeEventListener('keydown', on_edit_keydown); edit_observer.disconnect(); } }); edit_observer.observe(document.body, { childList: true, subtree: true }); // --- Sidebar --- const sidebar = document.createElement('div'); sidebar.className = 'edit-sidebar'; function sidebar_group(label) { const g = document.createElement('div'); g.className = 'edit-sidebar-group'; const l = document.createElement('div'); l.className = 'edit-sidebar-label'; l.textContent = label; g.appendChild(l); return g; } // Type const type_group = sidebar_group('Type'); let type_el; if (ae.mode === 'edit') { const et = state.entry_types.find(t => t.id === entry.type); type_el = document.createElement('span'); type_el.className = 'edit-sidebar-value'; type_el.textContent = et ? et.title : entry.type; } else { type_el = document.createElement('select'); type_el.className = 'edit-sidebar-select'; for (const et of state.entry_types) { const opt = document.createElement('option'); opt.value = et.id; opt.textContent = et.title; if (et.id === (ae.type ?? state.entry_types[0]?.id)) { opt.selected = true; } type_el.appendChild(opt); } } type_group.appendChild(type_el); sidebar.appendChild(type_group); // Status const status_group = sidebar_group('Status'); const status_sel = document.createElement('select'); status_sel.className = 'edit-sidebar-select'; for (const v of ['open', 'deferred', 'done', 'cancelled']) { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; if (v === (entry?.status ?? 'open')) { opt.selected = true; } status_sel.appendChild(opt); } status_group.appendChild(status_sel); sidebar.appendChild(status_group); // Priority const priority_group = sidebar_group('Priority'); const priority_sel = document.createElement('select'); priority_sel.className = 'edit-sidebar-select'; for (const v of ['high', 'normal', 'low']) { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; if (v === (entry?.priority ?? 'normal')) { opt.selected = true; } priority_sel.appendChild(opt); } priority_group.appendChild(priority_sel); sidebar.appendChild(priority_group); // Tags const tags_group = sidebar_group('Tags'); const tags_input = document.createElement('input'); tags_input.type = 'text'; tags_input.className = 'edit-sidebar-input'; tags_input.placeholder = 'comma-separated'; tags_input.value = (entry?.tags ?? []).join(', '); tags_group.appendChild(tags_input); sidebar.appendChild(tags_group); // Parent const parent_group = sidebar_group('Parent'); const parent_display = document.createElement('span'); parent_display.className = 'edit-sidebar-value'; function refresh_parent_display() { if (current_parent_id) { const p = state.entries.find(e => e.id === current_parent_id); parent_display.textContent = p ? `#${p.id} ${p.title}` : `#${current_parent_id}`; } else { parent_display.textContent = '—'; } } refresh_parent_display(); const parent_btns = document.createElement('div'); parent_btns.className = 'edit-parent-btns'; const set_parent_btn = document.createElement('button'); set_parent_btn.textContent = 'Set…'; set_parent_btn.addEventListener('click', () => { const excluded = entry ? new Set([entry.id, ...get_descendants(entry.id)]) : new Set(); if (current_parent_id) { excluded.add(current_parent_id); } entry_picker.open(excluded, id => { current_parent_id = id; refresh_parent_display(); show(clear_parent_btn); }); }); const clear_parent_btn = document.createElement('button'); clear_parent_btn.textContent = '× Clear'; if (!current_parent_id) { hide(clear_parent_btn); } clear_parent_btn.addEventListener('click', () => { current_parent_id = null; refresh_parent_display(); hide(clear_parent_btn); }); parent_btns.appendChild(set_parent_btn); parent_btns.appendChild(clear_parent_btn); parent_group.appendChild(parent_display); parent_group.appendChild(parent_btns); sidebar.appendChild(parent_group); const editor_pane = document.createElement('div'); editor_pane.className = 'edit-editor-pane'; editor_pane.appendChild(tab_bar); editor_pane.appendChild(cm_host); editor_pane.appendChild(preview_el); body_area.appendChild(editor_pane); body_area.appendChild(sidebar); view.appendChild(body_area); container.appendChild(view); if (!title_input.value) { title_input.focus(); } else { cm_editor.focus(); } // Save handler save_btn.addEventListener('click', async () => { const data = { type: ae.mode === 'new' ? (type_el.value ?? ae.type) : entry.type, title: title_input.value.trim(), body: get_editor_value().trim(), status: status_sel.value, priority: priority_sel.value, tags: tags_input.value.split(',').map(s => s.trim()).filter(Boolean), parent_id: current_parent_id, }; if (!data.title) { title_input.focus(); return; } try { save_btn.disabled = true; save_btn.textContent = 'Saving…'; if (ae.mode === 'edit') { const { entry: updated } = await api.update_entry(ae.id, data); const idx = state.entries.findIndex(e => e.id === ae.id); if (idx !== -1) { state.entries[idx] = updated; } md_cache.delete(entry.title); md_cache.delete(entry.body); navigate(String(ae.id)); } else { const { entry: created } = await api.create_entry(data); state.entries.unshift(created); navigate(String(created.id)); } } catch (err) { save_btn.disabled = false; save_btn.textContent = 'Save'; alert(err.message); } }); } // --------------------------------------------------------------------------- // Main render // --------------------------------------------------------------------------- function render() { render_nav(); const main = document.getElementById('main'); main.classList.toggle('edit-mode', state.active_view === 'edit'); if (state.active_view === 'manage-types') { render_manage_types(main); } else if (state.active_view === 'entry') { render_entry_detail(main); } else if (state.active_view === 'edit') { render_edit_view(main); } else { render_entries(main); } } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- function open_add_dialog(parent_id, type_id) { navigate_new(type_id, parent_id); } function open_edit_dialog(entry) { navigate('edit/' + entry.id); } function confirm_delete(entry) { const children_count = state.entries.filter(e => e.parent_id === entry.id).length; if (children_count > 0) { alert(`Cannot delete #${entry.id}: it has ${children_count} child entr${children_count === 1 ? 'y' : 'ies'}. Delete or reparent them first.`); return; } if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; } api.delete_entry(entry.id).then(() => { state.entries = state.entries.filter(e => e.id !== entry.id); if (state.active_view === 'entry') { navigate('type/' + entry.type); } else { render(); } }).catch(err => alert(err.message)); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { const tmpl_res = await fetch('/templates.html'); const tmpl_html = await tmpl_res.text(); const tmpl_container = document.createElement('div'); tmpl_container.innerHTML = tmpl_html; for (const tmpl of tmpl_container.querySelectorAll('template')) { document.body.appendChild(tmpl); } entry_picker = new Entry_Picker_Dialog(); entry_type_dialog = new Entry_Type_Dialog(); const [types_res, entries_res] = await Promise.all([ api.get_entry_types(), api.get_entries(), ]); state.entry_types = types_res.entry_types; state.entries = entries_res.entries; apply_search(); apply_hash(location.hash.slice(1)); render(); } init().catch(err => { console.error('Init failed:', err); document.getElementById('main').textContent = 'Failed to load: ' + err.message; });