943 lines
30 KiB
JavaScript
943 lines
30 KiB
JavaScript
import * as api from './lib/api.mjs';
|
|
import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class App_State {
|
|
constructor() {
|
|
this.entry_types = [];
|
|
this.entries = [];
|
|
this.active_type = null; // null = all types
|
|
this.active_view = 'entries'; // 'entries' | 'entry' | 'manage-types'
|
|
this.active_entry_id = null;
|
|
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; });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 body_el = row.querySelector('.task-body');
|
|
body_el.remove();
|
|
|
|
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);
|
|
}
|
|
|
|
row.querySelector('.btn-sub').addEventListener('click', () => open_add_dialog(entry.id, entry.type));
|
|
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) {
|
|
if (!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) {
|
|
const child_node = render_tree_node(child, children_map);
|
|
if (child_node) { children_el.appendChild(child_node); }
|
|
}
|
|
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;
|
|
} else if (hash.startsWith('type/')) {
|
|
state.active_view = 'entries';
|
|
state.active_type = hash.slice(5);
|
|
state.active_entry_id = null;
|
|
} else if (/^\d+$/.test(hash)) {
|
|
state.active_view = 'entry';
|
|
state.active_entry_id = Number(hash);
|
|
} else {
|
|
state.active_view = 'entries';
|
|
state.active_type = null;
|
|
state.active_entry_id = null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main render
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function render() {
|
|
render_nav();
|
|
const main = document.getElementById('main');
|
|
if (state.active_view === 'manage-types') {
|
|
render_manage_types(main);
|
|
} else if (state.active_view === 'entry') {
|
|
render_entry_detail(main);
|
|
} else {
|
|
render_entries(main);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
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_dialog = new Entry_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;
|
|
});
|