Add Gitea markdown rendering and Edit/Preview tabs

- New /api/render-markdown proxy to Gitea (configured via config.yaml)
- lib/config.mjs loads config.yaml with yaml-js
- Edit/Preview tabs on the body field in the task dialog
- Title and body rendered as markdown inline in the task list
- Gitea markup+chroma CSS extracted and vendored to public/gitea-markup.css
- Markdown cached in memory per text string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:19:29 +00:00
parent 1100720b03
commit 444b51794a
12 changed files with 369 additions and 12 deletions

View File

@@ -17,6 +17,30 @@ class App_State {
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 = text; // placeholder while loading
render_markdown(text).then(html => { el.innerHTML = html; });
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -54,6 +78,18 @@ class Task_Dialog {
this.form = el.querySelector('form');
this.on_save = 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(); }
});
}
el.querySelector('.btn-cancel').addEventListener('click', () => el.close());
this.form.addEventListener('submit', (e) => {
e.preventDefault();
@@ -62,6 +98,21 @@ class Task_Dialog {
});
}
_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();
@@ -81,12 +132,17 @@ class Task_Dialog {
this.form.elements['status'].value = initial.status ?? 'open';
this.form.elements['priority'].value = initial.priority ?? 'normal';
this.form.elements['tags'].value = (initial.tags ?? []).join(', ');
// Always open on edit tab
const tab_btns = this.dialog.querySelectorAll('.tab-btn');
const tab_panes = this.dialog.querySelectorAll('.tab-pane');
for (const b of tab_btns) { b.classList.toggle('active', b.dataset.tab === 'edit'); }
for (const p of tab_panes) { p.hidden = p.dataset.tab !== 'edit'; }
this.on_save = on_save;
this.dialog.showModal();
}
}
const task_dialog = new Task_Dialog();
let task_dialog;
// ---------------------------------------------------------------------------
// Render
@@ -179,7 +235,14 @@ function render_tasks(container) {
priority_el.textContent = task.priority;
priority_el.dataset.val = task.priority;
set_text(row, '.task-title', task.title);
const title_el = row.querySelector('.task-title');
fill_markdown(title_el, task.title);
const body_el = row.querySelector('.task-body');
if (task.body) {
show(body_el);
fill_markdown(body_el, task.body);
}
const tags_el = row.querySelector('.task-tags');
for (const tag of task.tags) {
@@ -225,6 +288,9 @@ function open_edit_dialog(task) {
const { task: updated } = await api.update_task(task.id, data);
const idx = state.tasks.findIndex(t => t.id === task.id);
if (idx !== -1) { state.tasks[idx] = updated; }
// Invalidate cache for changed fields
md_cache.delete(task.title);
md_cache.delete(task.body);
render();
} catch (err) {
alert(err.message);
@@ -254,6 +320,8 @@ async function init() {
document.body.appendChild(tmpl);
}
task_dialog = new Task_Dialog();
// Load data
const { tasks } = await api.get_tasks();
state.tasks = tasks;

123
public/gitea-markup.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Inventory</title>
<link rel="stylesheet" href="/gitea-markup.css">
<link rel="stylesheet" href="/style.css">
</head>
<body>

View File

@@ -10,7 +10,8 @@ async function req(method, path, body) {
return data;
}
export const get_tasks = () => req('GET', '/api/tasks');
export const create_task = (body) => req('POST', '/api/tasks', body);
export const update_task = (id, body) => req('PUT', `/api/tasks/${id}`, body);
export const delete_task = (id) => req('DELETE', `/api/tasks/${id}`);
export const get_tasks = () => req('GET', '/api/tasks');
export const create_task = (body) => req('POST', '/api/tasks', body);
export const update_task = (id, body) => req('PUT', `/api/tasks/${id}`, body);
export const delete_task = (id) => req('DELETE', `/api/tasks/${id}`);
export const render_markdown = (text) => req('POST', '/api/render-markdown', { text });

View File

@@ -152,6 +152,7 @@ button:hover { background: #444; color: #fff; }
/* Dialog */
dialog {
margin: auto;
background: #242424;
border: 1px solid #444;
border-radius: 8px;
@@ -202,3 +203,96 @@ dialog input:focus, dialog textarea:focus, dialog select:focus {
padding: 2rem;
text-align: center;
}
/* Markdown inline in task list */
.task-title.markup, .task-body.markup {
padding: 0;
font-size: 14px;
overflow: visible;
}
.task-title.markup p { margin: 0; display: inline; }
.task-title.markup > *:first-child { margin-top: 0; }
.task-body.markup {
margin-top: 0.25rem;
font-size: 12px;
color: #aaa;
max-height: 3.5em;
overflow: hidden;
}
.task-body.markup > *:first-child { margin-top: 0; }
.task-body.markup > *:last-child { margin-bottom: 0; }
/* Field with edit/preview tabs */
.field-with-tabs {
margin-bottom: 0.75rem;
}
.tab-bar {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 0;
border-bottom: 1px solid #444;
padding-bottom: 0;
}
.tab-btn {
padding: 0.25rem 0.75rem;
border-radius: 4px 4px 0 0;
border: 1px solid transparent;
border-bottom: none;
background: transparent;
color: #888;
font-size: 12px;
margin-bottom: -1px;
}
.tab-btn.active {
background: #1a1a1a;
color: #e0e0e0;
border-color: #444;
border-bottom-color: #1a1a1a;
}
.tab-btn:hover:not(.active) { color: #ccc; }
.tab-label {
margin-left: auto;
font-size: 11px;
color: #666;
padding-right: 0.25rem;
}
.tab-pane textarea {
width: 100%;
padding: 0.4rem 0.6rem;
background: #1a1a1a;
border: 1px solid #444;
border-top: none;
border-radius: 0 0 4px 4px;
color: #e0e0e0;
font-size: 13px;
font-family: monospace;
resize: vertical;
}
.tab-pane textarea:focus {
outline: none;
border-color: #5588e0;
}
.preview-content {
min-height: 8rem;
padding: 0.6rem 0.75rem;
background: #1a1a1a;
border: 1px solid #444;
border-top: none;
border-radius: 0 0 4px 4px;
font-size: 13px;
overflow: auto;
}
.preview-content.loading { color: #555; font-style: italic; }

View File

@@ -4,7 +4,10 @@
<span class="task-id"></span>
<span class="task-status"></span>
<span class="task-priority"></span>
<span class="task-title"></span>
<div class="task-main">
<div class="task-title markup"></div>
<div class="task-body markup" hidden></div>
</div>
<span class="task-tags"></span>
<div class="task-actions">
<button class="btn-edit">Edit</button>
@@ -21,9 +24,19 @@
<label>Title
<input name="title" type="text" required autocomplete="off">
</label>
<label>Body
<textarea name="body" rows="4"></textarea>
</label>
<div class="field-with-tabs">
<div class="tab-bar">
<button type="button" class="tab-btn active" data-tab="edit">Edit</button>
<button type="button" class="tab-btn" data-tab="preview">Preview</button>
<span class="tab-label">Body</span>
</div>
<div class="tab-pane" data-tab="edit">
<textarea name="body" rows="8"></textarea>
</div>
<div class="tab-pane" data-tab="preview" hidden>
<div class="markup preview-content"></div>
</div>
</div>
<label>Status
<select name="status">
<option value="open">Open</option>