Replace entry dialog with full-page edit view

- Remove Entry_Dialog class; edit/new now navigate to #edit/<id> and #new/...
- render_edit_view() builds a full-page layout: header with title input,
  Write/Preview tabs, and a metadata sidebar
- Tab key inserts a literal tab character in the editor
- Ctrl+S saves from both title input and textarea
- Body/main element uses flex column layout so edit view fills viewport

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 14:25:36 +00:00
parent ea4b767f0b
commit dbef6605c9
2 changed files with 489 additions and 165 deletions

View File

@@ -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 = '<em style="color:#555">Nothing to preview.</em>'; 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 = '<em style="color:#555">Nothing to preview.</em>';
} 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([

View File

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