- 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>
336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
import * as api from './lib/api.mjs';
|
|
import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class App_State {
|
|
constructor() {
|
|
this.tasks = [];
|
|
this.filter_status = 'open';
|
|
this.filter_priority = '';
|
|
this.filter_tag = '';
|
|
this.search = '';
|
|
}
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function filtered_tasks() {
|
|
return state.tasks.filter(t => {
|
|
if (state.filter_status && t.status !== state.filter_status) { return false; }
|
|
if (state.filter_priority && t.priority !== state.filter_priority) { return false; }
|
|
if (state.filter_tag && !t.tags.includes(state.filter_tag)) { return false; }
|
|
if (state.search) {
|
|
const q = state.search.toLowerCase();
|
|
if (!t.title.toLowerCase().includes(q) && !t.body.toLowerCase().includes(q)) { return false; }
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function all_tags() {
|
|
const tags = new Set();
|
|
for (const t of state.tasks) {
|
|
for (const tag of t.tags) { tags.add(tag); }
|
|
}
|
|
return [...tags].sort();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class Task_Dialog {
|
|
constructor() {
|
|
const el = clone('t-task-dialog');
|
|
document.body.appendChild(el);
|
|
this.dialog = el;
|
|
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();
|
|
if (this.on_save) { this.on_save(this._read_form()); }
|
|
el.close();
|
|
});
|
|
}
|
|
|
|
_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();
|
|
return {
|
|
title: fd.get('title').trim(),
|
|
body: fd.get('body').trim(),
|
|
status: fd.get('status'),
|
|
priority: fd.get('priority'),
|
|
tags: tags_raw ? tags_raw.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
};
|
|
}
|
|
|
|
open(title_text, initial = {}, on_save) {
|
|
set_text(this.dialog, '.dialog-title', title_text);
|
|
this.form.elements['title'].value = initial.title ?? '';
|
|
this.form.elements['body'].value = initial.body ?? '';
|
|
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();
|
|
}
|
|
}
|
|
|
|
let task_dialog;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function render_tasks(container) {
|
|
container.innerHTML = '';
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'toolbar';
|
|
toolbar.innerHTML = '<h2>Tasks</h2>';
|
|
const add_btn = document.createElement('button');
|
|
add_btn.className = 'btn-primary';
|
|
add_btn.textContent = '+ New task';
|
|
add_btn.addEventListener('click', () => open_add_dialog());
|
|
toolbar.appendChild(add_btn);
|
|
container.appendChild(toolbar);
|
|
|
|
// Filter bar
|
|
const filter_bar = document.createElement('div');
|
|
filter_bar.className = 'filter-bar';
|
|
|
|
const status_sel = document.createElement('select');
|
|
for (const [val, label] of [['', 'All statuses'], ['open', 'Open'], ['deferred', 'Deferred'], ['done', 'Done'], ['cancelled', 'Cancelled']]) {
|
|
const opt = document.createElement('option');
|
|
opt.value = val; opt.textContent = label;
|
|
if (val === state.filter_status) { opt.selected = true; }
|
|
status_sel.appendChild(opt);
|
|
}
|
|
status_sel.addEventListener('change', () => { state.filter_status = status_sel.value; render(); });
|
|
filter_bar.appendChild(status_sel);
|
|
|
|
const priority_sel = document.createElement('select');
|
|
for (const [val, label] of [['', 'All priorities'], ['high', 'High'], ['normal', 'Normal'], ['low', 'Low']]) {
|
|
const opt = document.createElement('option');
|
|
opt.value = val; opt.textContent = label;
|
|
if (val === state.filter_priority) { opt.selected = true; }
|
|
priority_sel.appendChild(opt);
|
|
}
|
|
priority_sel.addEventListener('change', () => { state.filter_priority = priority_sel.value; render(); });
|
|
filter_bar.appendChild(priority_sel);
|
|
|
|
const tag_sel = document.createElement('select');
|
|
const all_opt = document.createElement('option');
|
|
all_opt.value = ''; all_opt.textContent = 'All tags';
|
|
tag_sel.appendChild(all_opt);
|
|
for (const tag of all_tags()) {
|
|
const opt = document.createElement('option');
|
|
opt.value = tag; opt.textContent = tag;
|
|
if (tag === state.filter_tag) { opt.selected = true; }
|
|
tag_sel.appendChild(opt);
|
|
}
|
|
tag_sel.addEventListener('change', () => { state.filter_tag = tag_sel.value; render(); });
|
|
filter_bar.appendChild(tag_sel);
|
|
|
|
const search_input = document.createElement('input');
|
|
search_input.type = 'text';
|
|
search_input.placeholder = 'Search…';
|
|
search_input.value = state.search;
|
|
search_input.addEventListener('input', () => { state.search = search_input.value; render(); });
|
|
filter_bar.appendChild(search_input);
|
|
|
|
container.appendChild(filter_bar);
|
|
|
|
// Task list
|
|
const tasks = filtered_tasks();
|
|
const list = document.createElement('div');
|
|
list.className = 'task-list';
|
|
|
|
if (tasks.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty-state';
|
|
empty.textContent = 'No tasks.';
|
|
list.appendChild(empty);
|
|
}
|
|
|
|
for (const task of tasks) {
|
|
const row = clone('t-task-row');
|
|
row.dataset.priority = task.priority;
|
|
row.dataset.status = task.status;
|
|
|
|
set_text(row, '.task-id', `#${task.id}`);
|
|
|
|
const status_el = row.querySelector('.task-status');
|
|
status_el.textContent = task.status;
|
|
status_el.dataset.val = task.status;
|
|
|
|
const priority_el = row.querySelector('.task-priority');
|
|
priority_el.textContent = task.priority;
|
|
priority_el.dataset.val = task.priority;
|
|
|
|
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) {
|
|
const span = document.createElement('span');
|
|
span.className = 'tag';
|
|
span.textContent = tag;
|
|
tags_el.appendChild(span);
|
|
}
|
|
|
|
row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(task));
|
|
row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(task));
|
|
|
|
list.appendChild(row);
|
|
}
|
|
|
|
container.appendChild(list);
|
|
}
|
|
|
|
function render() {
|
|
const main = document.getElementById('main');
|
|
render_tasks(main);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function open_add_dialog() {
|
|
task_dialog.open('New task', {}, async (data) => {
|
|
try {
|
|
const { task } = await api.create_task(data);
|
|
state.tasks.unshift(task);
|
|
render();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function open_edit_dialog(task) {
|
|
task_dialog.open('Edit task', task, async (data) => {
|
|
try {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
function confirm_delete(task) {
|
|
if (!confirm(`Delete task #${task.id}: "${task.title}"?`)) { return; }
|
|
api.delete_task(task.id).then(() => {
|
|
state.tasks = state.tasks.filter(t => t.id !== task.id);
|
|
render();
|
|
}).catch(err => alert(err.message));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function init() {
|
|
// Inject templates
|
|
const tmpl_res = await fetch('/templates.html');
|
|
const tmpl_html = await tmpl_res.text();
|
|
const tmpl_container = document.createElement('div');
|
|
tmpl_container.innerHTML = tmpl_html;
|
|
for (const tmpl of tmpl_container.querySelectorAll('template')) {
|
|
document.body.appendChild(tmpl);
|
|
}
|
|
|
|
task_dialog = new Task_Dialog();
|
|
|
|
// Load data
|
|
const { tasks } = await api.get_tasks();
|
|
state.tasks = tasks;
|
|
|
|
render();
|
|
}
|
|
|
|
init().catch(err => {
|
|
console.error('Init failed:', err);
|
|
document.getElementById('main').textContent = 'Failed to load: ' + err.message;
|
|
});
|