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:
147
public/app.mjs
147
public/app.mjs
@@ -10,7 +10,8 @@ class App_State {
|
|||||||
this.entry_types = [];
|
this.entry_types = [];
|
||||||
this.entries = [];
|
this.entries = [];
|
||||||
this.active_type = null; // null = all types
|
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_status = 'open';
|
||||||
this.filter_priority = '';
|
this.filter_priority = '';
|
||||||
this.filter_tag = '';
|
this.filter_tag = '';
|
||||||
@@ -375,6 +376,10 @@ function make_entry_row(entry, children_map) {
|
|||||||
priority_el.textContent = entry.priority;
|
priority_el.textContent = entry.priority;
|
||||||
priority_el.dataset.val = 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);
|
fill_markdown(row.querySelector('.task-title'), entry.title);
|
||||||
|
|
||||||
const body_el = row.querySelector('.task-body');
|
const body_el = row.querySelector('.task-body');
|
||||||
@@ -678,12 +683,18 @@ function apply_hash(hash) {
|
|||||||
if (hash === 'manage-types') {
|
if (hash === 'manage-types') {
|
||||||
state.active_view = 'manage-types';
|
state.active_view = 'manage-types';
|
||||||
state.active_type = null;
|
state.active_type = null;
|
||||||
|
state.active_entry_id = null;
|
||||||
} else if (hash.startsWith('type/')) {
|
} else if (hash.startsWith('type/')) {
|
||||||
state.active_view = 'entries';
|
state.active_view = 'entries';
|
||||||
state.active_type = hash.slice(5);
|
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 {
|
} else {
|
||||||
state.active_view = 'entries';
|
state.active_view = 'entries';
|
||||||
state.active_type = null;
|
state.active_type = null;
|
||||||
|
state.active_entry_id = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,6 +709,133 @@ window.addEventListener('popstate', () => {
|
|||||||
render();
|
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
|
// Main render
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -707,6 +845,8 @@ function render() {
|
|||||||
const main = document.getElementById('main');
|
const main = document.getElementById('main');
|
||||||
if (state.active_view === 'manage-types') {
|
if (state.active_view === 'manage-types') {
|
||||||
render_manage_types(main);
|
render_manage_types(main);
|
||||||
|
} else if (state.active_view === 'entry') {
|
||||||
|
render_entry_detail(main);
|
||||||
} else {
|
} else {
|
||||||
render_entries(main);
|
render_entries(main);
|
||||||
}
|
}
|
||||||
@@ -739,6 +879,8 @@ function open_edit_dialog(entry) {
|
|||||||
if (idx !== -1) { state.entries[idx] = updated; }
|
if (idx !== -1) { state.entries[idx] = updated; }
|
||||||
md_cache.delete(entry.title);
|
md_cache.delete(entry.title);
|
||||||
md_cache.delete(entry.body);
|
md_cache.delete(entry.body);
|
||||||
|
md_cache.delete(updated.title);
|
||||||
|
md_cache.delete(updated.body);
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
@@ -755,7 +897,8 @@ function confirm_delete(entry) {
|
|||||||
if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; }
|
if (!confirm(`Delete #${entry.id}: "${entry.title}"?`)) { return; }
|
||||||
api.delete_entry(entry.id).then(() => {
|
api.delete_entry(entry.id).then(() => {
|
||||||
state.entries = state.entries.filter(e => e.id !== entry.id);
|
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));
|
}).catch(err => alert(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -492,3 +492,83 @@ dialog input:focus, dialog textarea:focus, dialog select:focus {
|
|||||||
|
|
||||||
.id-display-label { color: #999; min-width: 3rem; }
|
.id-display-label { color: #999; min-width: 3rem; }
|
||||||
.id-display-value { font-size: 13px; color: #ccc; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user