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;
+}