Files
task-inventory/public/app.mjs

1221 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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;
const priority_el = row.querySelector('.task-priority');
priority_el.textContent = entry.priority;
priority_el.dataset.val = 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';
status_el.textContent = entry.status;
status_el.dataset.val = entry.status;
meta.appendChild(status_el);
const priority_el = document.createElement('span');
priority_el.className = 'task-priority';
priority_el.textContent = entry.priority;
priority_el.dataset.val = entry.priority;
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 = '<em style="color:#555">Nothing to preview.</em>';
} 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;
});