diff --git a/public/app.mjs b/public/app.mjs index 5147632..02a840e 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -10,8 +10,9 @@ class App_State { this.entry_types = []; this.entries = []; this.active_type = null; // null = all types - this.active_view = 'entries'; // 'entries' | 'entry' | 'manage-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 = ''; @@ -160,141 +161,6 @@ class Entry_Picker_Dialog { let entry_picker; -// --------------------------------------------------------------------------- -// Entry dialog -// --------------------------------------------------------------------------- - -class Entry_Dialog { - constructor() { - const el = clone('t-entry-dialog'); - document.body.appendChild(el); - this.dialog = el; - this.form = el.querySelector('form'); - this.on_save = null; - this._editing_id = null; - - // Tab switching - const tab_btns = el.querySelectorAll('.tab-btn'); - const tab_panes = el.querySelectorAll('.tab-pane'); - for (const btn of tab_btns) { - btn.addEventListener('click', () => { - const target = btn.dataset.tab; - for (const b of tab_btns) { b.classList.toggle('active', b.dataset.tab === target); } - for (const p of tab_panes) { p.hidden = p.dataset.tab !== target; } - if (target === 'preview') { this._load_preview(); } - }); - } - - // Parent controls - el.querySelector('.btn-set-parent').addEventListener('click', () => { - const current_id = Number(this.form.elements['parent_id'].value) || null; - const self_id = this._editing_id; - const excluded = self_id ? new Set([self_id, ...get_descendants(self_id)]) : new Set(); - if (current_id) { excluded.add(current_id); } - entry_picker.open(excluded, (picked_id) => { this._set_parent(picked_id); }); - }); - - el.querySelector('.btn-clear-parent').addEventListener('click', () => { this._set_parent(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(); - }); - } - - _set_parent(parent_id) { - const hidden = this.form.elements['parent_id']; - const display = this.dialog.querySelector('.parent-display'); - const clear_btn = this.dialog.querySelector('.btn-clear-parent'); - if (parent_id) { - const parent = state.entries.find(e => e.id === parent_id); - hidden.value = parent_id; - display.textContent = parent ? `#${parent.id} ${parent.title}` : `#${parent_id}`; - show(clear_btn); - } else { - hidden.value = ''; - display.textContent = '—'; - hide(clear_btn); - } - } - - _load_preview() { - const text = this.form.elements['body'].value; - const preview = this.dialog.querySelector('.preview-content'); - if (!text.trim()) { preview.innerHTML = 'Nothing to preview.'; return; } - preview.classList.add('loading'); - preview.textContent = 'Loading…'; - render_markdown(text).then(html => { - preview.classList.remove('loading'); - preview.innerHTML = html; - }).catch(err => { - preview.classList.remove('loading'); - preview.textContent = 'Error: ' + err.message; - }); - } - - _read_form() { - const fd = new FormData(this.form); - const tags_raw = fd.get('tags').trim(); - const parent_id_raw = fd.get('parent_id'); - return { - type: fd.get('type'), - title: fd.get('title').trim(), - body: fd.get('body').trim(), - status: fd.get('status'), - priority: fd.get('priority'), - tags: tags_raw ? tags_raw.split(',').map(s => s.trim()).filter(Boolean) : [], - parent_id: parent_id_raw ? Number(parent_id_raw) : null, - }; - } - - // fixed_type: string — type is pre-set and shown as label; null — show select - open(title_text, initial = {}, fixed_type, on_save) { - this._editing_id = initial.id ?? null; - set_text(this.dialog, '.dialog-title', title_text); - - const type_display = this.dialog.querySelector('.type-display'); - const type_select = this.dialog.querySelector('.type-select'); - - // Always populate select options - type_select.innerHTML = ''; - for (const et of state.entry_types) { - const opt = document.createElement('option'); - opt.value = et.id; - opt.textContent = et.title; - type_select.appendChild(opt); - } - type_select.value = fixed_type ?? initial.type ?? (state.entry_types[0]?.id ?? ''); - - if (fixed_type) { - const et = state.entry_types.find(t => t.id === fixed_type); - type_display.textContent = et ? et.title : fixed_type; - show(type_display); - hide(type_select); - } else { - hide(type_display); - show(type_select); - } - - this.form.elements['title'].value = initial.title ?? ''; - this.form.elements['body'].value = initial.body ?? ''; - this.form.elements['status'].value = initial.status ?? 'open'; - this.form.elements['priority'].value = initial.priority ?? 'normal'; - this.form.elements['tags'].value = (initial.tags ?? []).join(', '); - this._set_parent(initial.parent_id ?? null); - - // Always open on edit tab - for (const b of this.dialog.querySelectorAll('.tab-btn')) { b.classList.toggle('active', b.dataset.tab === 'edit'); } - for (const p of this.dialog.querySelectorAll('.tab-pane')) { p.hidden = p.dataset.tab !== 'edit'; } - - this.on_save = on_save; - this.dialog.showModal(); - } -} - -let entry_dialog; - // --------------------------------------------------------------------------- // Entry type dialog // --------------------------------------------------------------------------- @@ -707,20 +573,43 @@ function apply_hash(hash) { state.active_view = 'manage-types'; state.active_type = null; state.active_entry_id = null; + state.active_edit = null; } else if (hash.startsWith('type/')) { state.active_view = 'entries'; state.active_type = hash.slice(5); state.active_entry_id = null; + state.active_edit = null; } 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 navigate(hash) { history.pushState(null, '', '#' + hash); apply_hash(hash); @@ -859,6 +748,289 @@ function render_entry_detail(container) { } } +// --------------------------------------------------------------------------- +// 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 : 'all'); + 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); + + // Textarea + const textarea = document.createElement('textarea'); + textarea.className = 'edit-textarea'; + textarea.value = entry?.body ?? ''; + textarea.placeholder = 'Body (markdown)'; + + // 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'); + textarea.hidden = false; + preview_el.hidden = true; + textarea.focus(); + }); + + preview_tab.addEventListener('click', () => { + preview_tab.classList.add('active'); + write_tab.classList.remove('active'); + textarea.hidden = true; + preview_el.hidden = false; + const text = textarea.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 to save + title_input.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); } + }); + textarea.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); } + if (e.key === 'Tab') { + e.preventDefault(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = textarea.value.slice(0, start) + '\t' + textarea.value.slice(end); + textarea.selectionStart = textarea.selectionEnd = start + 1; + } + }); + + // --- 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(textarea); + 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 { textarea.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: textarea.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 // --------------------------------------------------------------------------- @@ -866,10 +1038,13 @@ function render_entry_detail(container) { 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); } @@ -880,35 +1055,11 @@ function render() { // --------------------------------------------------------------------------- function open_add_dialog(parent_id, type_id) { - // If no type given and only one type exists, auto-select it - const resolved = type_id ?? (state.entry_types.length === 1 ? state.entry_types[0].id : null); - entry_dialog.open('New entry', { parent_id }, resolved, async (data) => { - try { - const { entry } = await api.create_entry(data); - state.entries.unshift(entry); - if (parent_id) { state.expanded.add(parent_id); } - render(); - } catch (err) { - alert(err.message); - } - }); + navigate_new(type_id, parent_id); } function open_edit_dialog(entry) { - entry_dialog.open('Edit entry', entry, entry.type, async (data) => { - try { - const { entry: updated } = await api.update_entry(entry.id, data); - const idx = state.entries.findIndex(e => e.id === entry.id); - if (idx !== -1) { state.entries[idx] = updated; } - md_cache.delete(entry.title); - md_cache.delete(entry.body); - md_cache.delete(updated.title); - md_cache.delete(updated.body); - render(); - } catch (err) { - alert(err.message); - } - }); + navigate('edit/' + entry.id); } function confirm_delete(entry) { @@ -938,8 +1089,7 @@ async function init() { document.body.appendChild(tmpl); } - entry_picker = new Entry_Picker_Dialog(); - entry_dialog = new Entry_Dialog(); + entry_picker = new Entry_Picker_Dialog(); entry_type_dialog = new Entry_Type_Dialog(); const [types_res, entries_res] = await Promise.all([ diff --git a/public/style.css b/public/style.css index 4bbe636..a7d2e04 100644 --- a/public/style.css +++ b/public/style.css @@ -6,7 +6,10 @@ body { font-size: 14px; background: #1a1a1a; color: #e0e0e0; - min-height: 100vh; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } header { @@ -608,3 +611,174 @@ dialog input:focus, dialog textarea:focus, dialog select:focus { .detail-children .task-list { margin-top: 0.5rem; } + +/* Full-page edit mode */ +header { flex-shrink: 0; } + +main { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +main.edit-mode { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.edit-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.edit-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: #1e1e1e; + border-bottom: 1px solid #333; + flex-shrink: 0; +} + +.edit-title-input { + flex: 1; + padding: 0.35rem 0.6rem; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 15px; + font-family: inherit; + font-weight: 500; +} + +.edit-title-input:focus { + outline: none; + border-color: #5588e0; +} + +.edit-body-area { + display: grid; + grid-template-columns: 1fr 240px; + flex: 1; + overflow: hidden; + min-height: 0; +} + +.edit-editor-pane { + display: flex; + flex-direction: column; + border-right: 1px solid #2a2a2a; + overflow: hidden; + min-height: 0; +} + +.edit-tab-bar { + display: flex; + gap: 0; + border-bottom: 1px solid #2a2a2a; + flex-shrink: 0; + background: #161616; +} + +.edit-tab { + padding: 0.4rem 1rem; + border: none; + border-radius: 0; + background: transparent; + color: #666; + font-size: 13px; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.edit-tab:hover { color: #aaa; background: transparent; } +.edit-tab.active { color: #e0e0e0; border-bottom-color: #5588e0; background: transparent; } + +.edit-textarea { + flex: 1; + resize: none; + padding: 1rem; + background: #111; + border: none; + color: #d0d0d0; + font-size: 13px; + font-family: monospace; + line-height: 1.6; + overflow-y: auto; + min-height: 0; +} + +.edit-textarea:focus { + outline: none; + background: #141414; +} + +.edit-preview { + flex: 1; + padding: 1rem 1.25rem; + overflow-y: auto; + line-height: 1.6; + font-size: 14px; + background: #111; + min-height: 0; +} + + +.edit-sidebar { + overflow-y: auto; + padding: 0.75rem; + background: #1a1a1a; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.edit-sidebar-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.edit-sidebar-label { + font-size: 11px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.edit-sidebar-value { + font-size: 13px; + color: #ccc; + padding: 0.2rem 0; +} + +.edit-sidebar-select, +.edit-sidebar-input { + width: 100%; + padding: 0.3rem 0.5rem; + background: #242424; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 13px; + font-family: inherit; +} + +.edit-sidebar-select:focus, +.edit-sidebar-input:focus { + outline: none; + border-color: #5588e0; +} + +.edit-parent-btns { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: 0.25rem; +}