390 lines
16 KiB
HTML
390 lines
16 KiB
HTML
{% 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&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&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&A saved yet</p>
|
|
<p class="mt-1">Click "Add new Q&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&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&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&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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
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 %}
|