- Add CodeMirror 6 with oneDark theme and markdown language support - Bundle via esbuild into public/vendor/ (built on deploy, not committed) - Add codemirror-entry.mjs as bundle entry point - Fix SPA fallback to only serve index.html for /, not all paths - Fix mime type handling by mutating mime-types registry - Add Write/Preview tabs; preview renders on tab switch only - Multi-cursor via Shift+Alt+Up/Down; Alt+Click adds cursor - gitignore package-lock.json and public/vendor/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1147 lines
36 KiB
JavaScript
1147 lines
36 KiB
JavaScript
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 — only shown in "All" view
|
||
const type_chip = row.querySelector('.entry-type-chip');
|
||
if (!state.active_type) {
|
||
const et = state.entry_types.find(t => t.id === entry.type);
|
||
type_chip.textContent = et ? et.title : 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();
|
||
state.filter_tag = tag;
|
||
render();
|
||
});
|
||
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, is_child = false) {
|
||
if (!is_child && !node_or_descendants_match(entry, children_map)) { return null; }
|
||
|
||
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, true));
|
||
}
|
||
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('all'));
|
||
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', () => { state.filter_status = status_sel.value; render(); });
|
||
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', () => { state.filter_priority = priority_sel.value; render(); });
|
||
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', () => { state.filter_tag = tag_sel.value; render(); });
|
||
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', () => { state.search = search_input.value; render(); });
|
||
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 => node_or_descendants_match(e, children_map));
|
||
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.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);
|
||
render();
|
||
}
|
||
|
||
window.addEventListener('popstate', () => {
|
||
apply_hash(location.hash.slice(1));
|
||
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;
|
||
|
||
// 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;
|
||
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';
|
||
span.textContent = 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 : '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);
|
||
|
||
// 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,
|
||
{ key: 'Ctrl-s', mac: 'Cmd-s', run: () => { save_btn.click(); return true; } },
|
||
]),
|
||
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
|
||
title_input.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); }
|
||
});
|
||
|
||
// --- 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_hash(location.hash.slice(1));
|
||
render();
|
||
}
|
||
|
||
init().catch(err => {
|
||
console.error('Init failed:', err);
|
||
document.getElementById('main').textContent = 'Failed to load: ' + err.message;
|
||
});
|