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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.tmp
|
||||
config.yaml
|
||||
|
||||
3
config.yaml.example
Normal file
3
config.yaml.example
Normal file
@@ -0,0 +1,3 @@
|
||||
gitea:
|
||||
url: https://gitea.efforting.tech
|
||||
token: your_token_here
|
||||
16
lib/config.mjs
Normal file
16
lib/config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as fs from 'node:fs';
|
||||
import yaml from 'yaml-js';
|
||||
|
||||
let _config = null;
|
||||
|
||||
export function get_config() {
|
||||
if (_config !== null) { return _config; }
|
||||
try {
|
||||
const content = fs.readFileSync('./config.yaml', 'utf-8');
|
||||
_config = yaml.load(content);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') { _config = {}; }
|
||||
else { throw e; }
|
||||
}
|
||||
return _config;
|
||||
}
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -8,7 +8,8 @@
|
||||
"name": "task-inventory",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"yaml-js": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
@@ -841,6 +842,15 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-js": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml-js/-/yaml-js-0.3.1.tgz",
|
||||
"integrity": "sha512-LjoIFHCtSfkGzPsmYmfDsW+TShtQBcY7lwH1yLQ5brJHXRhjteUnVE2ThCbz1madq8JUZIAjFiavfnIFeTO8CQ==",
|
||||
"license": "WTFPL",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"yaml-js": "^0.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
123
public/gitea-markup.css
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -14,3 +14,4 @@ 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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
server.mjs
26
server.mjs
@@ -4,6 +4,7 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException
|
||||
import express from 'express';
|
||||
import { next_id } from './lib/ids.mjs';
|
||||
import { list_tasks, get_task, set_task, delete_task } from './lib/storage.mjs';
|
||||
import { get_config } from './lib/config.mjs';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -15,6 +16,31 @@ const BIND_ADDRESS = process.env.BIND_ADDRESS ?? 'localhost';
|
||||
function ok(res, data = {}) { res.json({ ok: true, ...data }); }
|
||||
function fail(res, msg, status = 400) { res.status(status).json({ ok: false, error: msg }); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown rendering proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/render-markdown', async (req, res) => {
|
||||
const { text, mode = 'markdown' } = req.body;
|
||||
if (typeof text !== 'string') { return fail(res, 'text is required'); }
|
||||
const config = get_config();
|
||||
const gitea_url = config.gitea?.url;
|
||||
const token = config.gitea?.token;
|
||||
if (!gitea_url) { return fail(res, 'Gitea not configured (missing config.yaml)'); }
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) { headers['Authorization'] = `token ${token}`; }
|
||||
const gitea_res = await fetch(`${gitea_url}/api/v1/markdown`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ Text: text, Mode: mode }),
|
||||
});
|
||||
if (!gitea_res.ok) {
|
||||
return fail(res, `Gitea error: ${gitea_res.status} ${gitea_res.statusText}`);
|
||||
}
|
||||
const html = await gitea_res.text();
|
||||
ok(res, { html });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user