feat(dashboard): add corpus dashboard at /dashboard/

Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.

- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
  save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css  assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning

Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 17:15:40 +02:00
parent 83fc71414f
commit 06d01a3bce
20 changed files with 2632 additions and 28 deletions
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
$dashboardPage = 'documents';
$dashboardTitle = 'Dokument';
$dashboardLead = '';
require_once __DIR__ . '/../includes/layout_dashboard.php';
$docId = (int)($_GET['id'] ?? 0);
?>
<div id="docViewRoot" data-doc-id="<?= $docId ?>">
<p class="dash-loading">Laster dokument…</p>
</div>
<script>
(function () {
'use strict';
const root = document.getElementById('docViewRoot');
const docId = parseInt(root.dataset.docId, 10);
const api = window.DBN_DASHBOARD.apiBase;
if (!docId) {
root.innerHTML = '<div class="dash-error">Mangler dokument-id.</div>';
return;
}
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleString('nb-NO', { dateStyle:'medium', timeStyle:'short' }); }
catch (_) { return s; }
}
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString('nb-NO'); }
function statusPill(status) {
const cls = { ready:'dash-status--ready', pending:'dash-status--pending', processing:'dash-status--processing', error:'dash-status--error' }[status] || 'dash-status--pending';
const lbl = { ready:'Klar', pending:'Venter', processing:'Behandler', error:'Feil' }[status] || status;
return '<span class="dash-status ' + cls + '">' + lbl + '</span>';
}
fetch(api + '/documents.php?action=get&id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Dokument ikke funnet');
render(data.document, data.chunks || []);
})
.catch(err => {
root.innerHTML = '<div class="dash-error">' + safe(err.message)
+ '</div><p><a href="/dashboard/documents.php" class="dash-btn">← Tilbake</a></p>';
});
function render(doc, chunks) {
const html =
'<section class="dash-card">'
+ '<div class="dash-doc-head">'
+ '<div>'
+ '<h2>' + safe(doc.title) + '</h2>'
+ '<p class="dash-doc-meta">' + statusPill(doc.status)
+ ' · ' + fmtNum(doc.word_count) + ' ord'
+ ' · ' + fmtNum(doc.chunk_count) + ' passasjer'
+ ' · lagt til ' + fmtDate(doc.created_at) + '</p>'
+ (doc.tags ? '<p class="dash-doc-meta">Tagger: ' + safe(doc.tags) + '</p>' : '')
+ (doc.source_url ? '<p class="dash-doc-meta"><a href="' + safe(doc.source_url) + '" target="_blank" rel="noopener">Original kilde ↗</a></p>' : '')
+ '</div>'
+ '<div>'
+ '<a href="/dashboard/documents.php" class="dash-btn">← Tilbake</a> '
+ '<button class="dash-btn dash-btn--danger" id="docDelete">Slett</button>'
+ '</div>'
+ '</div>'
+ '<nav class="dash-tabs" role="tablist">'
+ '<button class="dash-tab is-active" data-tab="preview" role="tab">Forhåndsvisning</button>'
+ '<button class="dash-tab" data-tab="chunks" role="tab">Passasjer (' + fmtNum(doc.chunk_count) + ')</button>'
+ '<button class="dash-tab" data-tab="related" role="tab">Relatert</button>'
+ '<button class="dash-tab" data-tab="edit" role="tab">Rediger</button>'
+ '</nav>'
+ '<div class="dash-tab-panel is-active" data-panel="preview">'
+ '<div class="dash-preview">' + safe(doc.content || '(tom)') + '</div>'
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="chunks">'
+ (chunks.length
? chunks.map(c =>
'<article class="dash-chunk">'
+ (c.section_title ? '<p class="dash-chunk__section">' + safe(c.section_title) + '</p>' : '')
+ safe(c.content) + '</article>').join('')
: '<div class="dash-empty">Ingen passasjer indeksert ennå.</div>')
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="related">'
+ '<div class="dash-loading" id="relatedLoading">Laster relaterte autoriteter fra graphen…</div>'
+ '<div class="dash-related" id="relatedList" hidden></div>'
+ '</div>'
+ '<div class="dash-tab-panel" data-panel="edit">'
+ '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">'
+ '<label>Tittel<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Kategori<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Tagger (komma-separert)<input name="tags" value="' + safe(doc.tags || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Språk<input name="language" value="' + safe(doc.language || 'no') + '" maxlength="10" style="width:120px;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<label>Forfatter<input name="author" value="' + safe(doc.author || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
+ '<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Lagre endringer</button>'
+ '<span id="docEditStatus" style="color:rgba(22,19,15,0.6);font-size:0.85rem;"></span>'
+ '</form>'
+ '</div>'
+ '</section>';
root.innerHTML = html;
wireTabs();
wireDelete();
wireEdit();
}
function wireTabs() {
const tabs = root.querySelectorAll('.dash-tab');
const panels = root.querySelectorAll('.dash-tab-panel');
tabs.forEach(t => t.addEventListener('click', () => {
tabs.forEach(x => x.classList.remove('is-active'));
panels.forEach(p => p.classList.remove('is-active'));
t.classList.add('is-active');
const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]');
if (panel) panel.classList.add('is-active');
if (t.dataset.tab === 'related') loadRelated();
}));
}
let relatedLoaded = false;
function loadRelated() {
if (relatedLoaded) return;
relatedLoaded = true;
const list = document.getElementById('relatedList');
const loading = document.getElementById('relatedLoading');
fetch(api + '/graph.php?action=cites&doc_id=' + docId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
loading.hidden = true;
list.hidden = false;
const items = data.results || [];
if (!items.length) {
list.innerHTML = '<div class="dash-empty">Dokumentet har ingen kjente siteringer i graf-databasen ennå.</div>';
return;
}
list.innerHTML = items.map(it =>
'<div class="dash-related__edge">'
+ '<span class="dash-related__rel">' + safe(it.rel_type || '—') + '</span>'
+ '<span class="dash-related__title">' + safe(it.title || it.ref || 'Ukjent') + '</span>'
+ '</div>').join('');
})
.catch(_ => {
loading.hidden = true;
list.hidden = false;
list.innerHTML = '<div class="dash-empty">Graf-databasen er ikke tilgjengelig akkurat nå.</div>';
});
}
function wireDelete() {
const btn = document.getElementById('docDelete');
if (!btn) return;
btn.addEventListener('click', () => {
if (!confirm('Slette dette dokumentet permanent?')) return;
btn.disabled = true;
fetch(api + '/documents.php?action=delete', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [docId] }),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Slett feilet');
location.href = '/dashboard/documents.php';
})
.catch(err => { alert('Feil: ' + err.message); btn.disabled = false; });
});
}
function wireEdit() {
const form = document.getElementById('docEditForm');
const status = document.getElementById('docEditStatus');
if (!form) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
const payload = { id: docId };
['title', 'category', 'tags', 'language', 'author'].forEach(name => {
const el = form.elements[name];
if (el) payload[name] = el.value;
});
status.textContent = 'Lagrer…';
fetch(api + '/documents.php?action=update', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error?.message || 'Lagring feilet');
status.textContent = 'Lagret ' + new Date().toLocaleTimeString('nb-NO');
})
.catch(err => status.textContent = 'Feil: ' + err.message);
});
}
})();
</script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>