diff --git a/public/app.mjs b/public/app.mjs index fd35e03..bd34a32 100644 --- a/public/app.mjs +++ b/public/app.mjs @@ -10,7 +10,8 @@ class App_State { this.entry_types = []; this.entries = []; this.active_type = null; // null = all types - this.active_view = 'entries'; // 'entries' | 'manage-types' + this.active_view = 'entries'; // 'entries' | 'entry' | 'manage-types' + this.active_entry_id = null; this.filter_status = 'open'; this.filter_priority = ''; this.filter_tag = ''; @@ -375,6 +376,10 @@ function make_entry_row(entry, children_map) { 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'); @@ -678,12 +683,18 @@ 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; } } @@ -698,6 +709,133 @@ window.addEventListener('popstate', () => { 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 // --------------------------------------------------------------------------- @@ -707,6 +845,8 @@ function render() { 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); } @@ -739,6 +879,8 @@ function open_edit_dialog(entry) { 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); @@ -755,7 +897,8 @@ function confirm_delete(entry) { if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; } api.delete_entry(entry.id).then(() => { state.entries = state.entries.filter(e => e.id !== entry.id); - render(); + if (state.active_view === 'entry') { navigate('type/' + entry.type); } + else { render(); } }).catch(err => alert(err.message)); } diff --git a/public/style.css b/public/style.css index b9f0ee9..9f5f551 100644 --- a/public/style.css +++ b/public/style.css @@ -492,3 +492,83 @@ dialog input:focus, dialog textarea:focus, dialog select:focus { .id-display-label { color: #999; min-width: 3rem; } .id-display-value { font-size: 13px; color: #ccc; } + +/* Clickable entry row main area */ +.task-main.clickable { + cursor: pointer; +} +.task-main.clickable:hover .task-title { + color: #fff; +} + +/* Entry detail view */ +.detail-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.detail-back { + color: #666; + text-decoration: none; + font-size: 13px; +} +.detail-back:hover { color: #aaa; } + +.detail-id { + color: #555; + font-size: 13px; +} + +.detail-actions { + margin-left: auto; + display: flex; + gap: 0.5rem; +} + +.detail-title { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 0.75rem; + line-height: 1.3; +} + +.detail-title p { margin: 0; } + +.detail-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.detail-parent-link { + font-size: 12px; + color: #666; + text-decoration: none; +} +.detail-parent-link:hover { color: #aaa; } + +.detail-body { + background: #1e1e1e; + border: 1px solid #2e2e2e; + border-radius: 6px; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + line-height: 1.6; +} + +.detail-children h3 { + font-size: 13px; + color: #666; + font-weight: 500; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-children .task-list { + margin-top: 0.5rem; +}