390 lines
16 KiB
HTML
Raw Normal View History

2026-03-13 00:18:14 +00:00
{% extends "base.html" %}
{% block title %}Q&A — Ontoref{% endblock title %}
{% block nav_qa %}active{% endblock nav_qa %}
{% block mob_nav_qa %}active{% endblock mob_nav_qa %}
{% block content %}
<!-- Hidden context for JS -->
<input type="hidden" id="qa-project" value="{% if slug %}{{ slug }}{% else %}__single__{% endif %}">
<!-- Page header -->
<div class="mb-4 flex items-center gap-3">
<h1 class="text-xl font-bold flex items-center gap-2">
Q&amp;A Bookmarks
<span id="qa-count" class="badge badge-ghost badge-sm font-mono hidden">0</span>
</h1>
<div class="flex-1"></div>
<button id="btn-add-new" class="btn btn-sm btn-primary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add new Q&amp;A
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[calc(100vh-200px)] min-h-[480px]">
<!-- Left column: list -->
<div class="flex flex-col bg-base-200 rounded-lg overflow-hidden border border-base-content/10">
<!-- Search filter -->
<div class="p-3 border-b border-base-content/10 flex-shrink-0">
<div class="relative">
<input id="qa-filter" type="search" placeholder="Filter by question…"
class="input input-bordered input-sm w-full pr-8 font-mono"
autocomplete="off" spellcheck="false">
<svg class="absolute right-2.5 top-2 w-4 h-4 text-base-content/30 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</div>
</div>
<!-- List -->
<ul id="qa-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
<!-- Empty state for list -->
<div id="qa-list-empty" class="flex-1 flex flex-col items-center justify-center text-base-content/30 text-xs p-6 hidden">
<svg class="w-10 h-10 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p>No Q&amp;A saved yet</p>
<p class="mt-1">Click "Add new Q&amp;A" to get started</p>
</div>
</div>
<!-- Right column: detail / form -->
<div class="flex flex-col bg-base-200 rounded-lg overflow-hidden border border-base-content/10">
<!-- Empty state (default) -->
<div id="qa-detail-empty" class="flex-1 flex flex-col items-center justify-center text-base-content/30 text-sm">
<svg class="w-10 h-10 mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Select a Q&amp;A or add a new one
</div>
<!-- View panel -->
<div id="qa-view" class="flex-1 flex flex-col overflow-hidden hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-base-content/10 flex-shrink-0">
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Q&amp;A</span>
<div class="flex gap-1">
<button id="btn-edit-qa" class="btn btn-xs btn-ghost">Edit</button>
<button id="btn-delete-qa" class="btn btn-xs btn-ghost text-error">Delete</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<div class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">Question</div>
<p id="qa-view-question" class="text-sm leading-relaxed whitespace-pre-wrap"></p>
</div>
<div>
<div class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">Answer</div>
<p id="qa-view-answer" class="text-sm leading-relaxed whitespace-pre-wrap text-base-content/80"></p>
</div>
<div class="text-xs text-base-content/30 font-mono" id="qa-view-meta"></div>
</div>
</div>
<!-- Edit / Add form -->
<div id="qa-form" class="flex-1 flex flex-col overflow-hidden hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-base-content/10 flex-shrink-0">
<span id="qa-form-title" class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">New Q&amp;A</span>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Question</span></label>
<textarea id="qa-input-question" rows="4"
placeholder="What is this project's primary architecture constraint?"
class="textarea textarea-bordered textarea-sm w-full font-mono resize-y"></textarea>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Answer <span class="text-base-content/40">(optional — fill later)</span></span></label>
<textarea id="qa-input-answer" rows="6"
placeholder="Leave blank to fill in later…"
class="textarea textarea-bordered textarea-sm w-full resize-y"></textarea>
</div>
</div>
</div>
<div class="flex items-center gap-2 justify-end px-4 py-3 border-t border-base-content/10 flex-shrink-0">
<span id="qa-save-status" class="text-xs text-error flex-1"></span>
<button id="btn-form-cancel" class="btn btn-sm btn-ghost">Cancel</button>
<button id="btn-form-save" class="btn btn-sm btn-primary">Save to DAG</button>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
(function () {
const projectEl = document.getElementById('qa-project');
const PROJECT = projectEl ? projectEl.value : '__single__';
// Server-side entries injected at render time (NCL-backed, canonical).
const SERVER_ENTRIES = {{ entries | default(value=[]) | json_encode() }};
let items = SERVER_ENTRIES;
let selectedId = null;
let editingId = null;
let filterText = '';
// ── DOM refs ─────────────────────────────────────────────────────────────
const qaList = document.getElementById('qa-list');
const qaListEmpty = document.getElementById('qa-list-empty');
const qaCount = document.getElementById('qa-count');
const qaFilter = document.getElementById('qa-filter');
const detailEmpty = document.getElementById('qa-detail-empty');
const viewPanel = document.getElementById('qa-view');
const formPanel = document.getElementById('qa-form');
const viewQuestion = document.getElementById('qa-view-question');
const viewAnswer = document.getElementById('qa-view-answer');
const viewMeta = document.getElementById('qa-view-meta');
const formTitle = document.getElementById('qa-form-title');
const inputQuestion = document.getElementById('qa-input-question');
const inputAnswer = document.getElementById('qa-input-answer');
const btnAddNew = document.getElementById('btn-add-new');
const btnEditQa = document.getElementById('btn-edit-qa');
const btnDeleteQa = document.getElementById('btn-delete-qa');
const btnFormCancel = document.getElementById('btn-form-cancel');
const btnFormSave = document.getElementById('btn-form-save');
const saveStatus = document.getElementById('qa-save-status');
// ── Helpers ───────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString(); } catch (_) { return iso; }
}
// ── Render list ──────────────────────────────────────────────────────────
function renderList() {
const filtered = filterText
? items.filter(it => it.question.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (items.length === 0) {
qaListEmpty.classList.remove('hidden');
qaList.innerHTML = '';
} else {
qaListEmpty.classList.add('hidden');
}
qaCount.textContent = items.length;
if (items.length > 0) {
qaCount.classList.remove('hidden');
} else {
qaCount.classList.add('hidden');
}
if (filtered.length === 0 && items.length > 0) {
qaList.innerHTML = '<li class="px-3 py-8 text-center text-xs text-base-content/40">No matches</li>';
return;
}
qaList.innerHTML = filtered.map(it => {
const isSelected = it.id === selectedId;
const preview = it.answer
? it.answer.replace(/\n/g, ' ').slice(0, 80) + (it.answer.length > 80 ? '…' : '')
: '';
return `
<li class="qa-item cursor-pointer hover:bg-base-300 transition-colors ${isSelected ? 'bg-base-300' : ''}"
data-id="${esc(it.id)}">
<div class="px-3 py-2.5 flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-tight truncate">${esc(it.question)}</div>
${preview ? `<div class="text-xs text-base-content/40 leading-tight truncate mt-0.5">${esc(preview)}</div>` : ''}
</div>
<button class="btn-delete-item btn btn-ghost btn-xs btn-circle flex-shrink-0 opacity-30 hover:opacity-100 hover:text-error"
data-id="${esc(it.id)}" title="Delete">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</li>`;
}).join('');
qaList.querySelectorAll('.qa-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.btn-delete-item')) return;
selectItem(el.dataset.id);
});
});
qaList.querySelectorAll('.btn-delete-item').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
deleteItem(el.dataset.id);
});
});
}
// ── Select / view ────────────────────────────────────────────────────────
function selectItem(id) {
selectedId = id;
editingId = null;
const it = items.find(x => x.id === id);
if (!it) return;
showPanel('view');
viewQuestion.textContent = it.question;
viewAnswer.textContent = it.answer || '—';
const ts = it.created_at || it.saved_at || '';
viewMeta.textContent = (ts ? 'saved: ' + fmtDate(ts) + ' · ' : '') + 'id: ' + it.id;
renderList();
}
async function deleteItem(id) {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
try {
await fetch('/qa/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, slug }),
});
} catch (_) { /* best-effort */ }
items = items.filter(x => x.id !== id);
if (selectedId === id) { selectedId = null; showPanel('empty'); }
renderList();
}
// ── Panels ───────────────────────────────────────────────────────────────
function showPanel(which) {
detailEmpty.classList.add('hidden');
viewPanel.classList.add('hidden');
formPanel.classList.add('hidden');
if (which === 'view') viewPanel.classList.remove('hidden');
if (which === 'form') formPanel.classList.remove('hidden');
if (which === 'empty') detailEmpty.classList.remove('hidden');
}
// ── Add / Edit form ──────────────────────────────────────────────────────
function openAddForm() {
editingId = null;
selectedId = null;
formTitle.textContent = 'New Q&A';
inputQuestion.value = '';
inputAnswer.value = '';
showPanel('form');
renderList();
inputQuestion.focus();
}
function openEditForm(id) {
const it = items.find(x => x.id === id);
if (!it) return;
editingId = id;
formTitle.textContent = 'Edit Q&A';
inputQuestion.value = it.question;
inputAnswer.value = it.answer || '';
showPanel('form');
inputQuestion.focus();
}
async function saveForm() {
const q = inputQuestion.value.trim();
if (!q) { inputQuestion.focus(); return; }
const a = inputAnswer.value.trim();
if (editingId) {
if (saveStatus) saveStatus.textContent = 'Saving…';
btnFormSave.disabled = true;
try {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
const res = await fetch('/qa/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingId, question: q, answer: a, slug }),
});
const data = await res.json();
if (data.ok) {
const idx = items.findIndex(x => x.id === editingId);
if (idx >= 0) items[idx] = { ...items[idx], question: q, answer: a };
selectedId = editingId;
editingId = null;
selectItem(selectedId);
if (saveStatus) saveStatus.textContent = '';
} else {
if (saveStatus) saveStatus.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (_) {
if (saveStatus) saveStatus.textContent = 'Network error';
} finally {
btnFormSave.disabled = false;
}
renderList();
return;
}
// New entry — POST to server for NCL persistence.
if (saveStatus) saveStatus.textContent = 'Saving…';
btnFormSave.disabled = true;
try {
const slug = PROJECT !== '__single__' ? PROJECT : undefined;
const res = await fetch('/qa/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q, answer: a, actor: 'human', slug }),
});
const data = await res.json();
if (data.ok) {
const entry = { id: data.id, question: q, answer: a, created_at: data.created_at };
items = [entry, ...items];
selectedId = entry.id;
editingId = null;
selectItem(entry.id);
if (saveStatus) saveStatus.textContent = '';
} else {
if (saveStatus) saveStatus.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (err) {
if (saveStatus) saveStatus.textContent = 'Network error';
} finally {
btnFormSave.disabled = false;
}
renderList();
}
// ── Events ───────────────────────────────────────────────────────────────
btnAddNew.addEventListener('click', openAddForm);
btnEditQa.addEventListener('click', () => { if (selectedId) openEditForm(selectedId); });
btnDeleteQa.addEventListener('click', () => { if (selectedId) deleteItem(selectedId); });
btnFormCancel.addEventListener('click', () => {
editingId = null;
if (selectedId) {
selectItem(selectedId);
} else {
showPanel('empty');
}
});
btnFormSave.addEventListener('click', saveForm);
qaFilter.addEventListener('input', () => {
filterText = qaFilter.value;
renderList();
});
// ── Init ─────────────────────────────────────────────────────────────────
renderList();
if (items.length === 0) {
showPanel('empty');
}
})();
</script>
{% endblock scripts %}