Add entry detail view and #id URL routing

- #<number> navigates to a full detail view (rendered title + body,
  meta badges, parent link, children list, edit/delete/sub buttons)
- Clicking entry row main area navigates to detail instead of opening edit
- Edit dialog remains for editing, delete redirects back to list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:19:02 +00:00
parent c5a5966318
commit 3514d57b00
2 changed files with 225 additions and 2 deletions

View File

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

View File

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