06d01a3bce
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>
190 lines
8.3 KiB
PHP
190 lines
8.3 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
$dashboardPage = 'documents';
|
||
$dashboardTitle = 'Dokumenter';
|
||
$dashboardLead = 'Alle dokumenter i din private korpus. Klikk for å åpne, eller velg flere for bulk-handlinger.';
|
||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||
?>
|
||
<section class="dash-card">
|
||
<div class="dash-filters">
|
||
<input type="search" id="docFilterQ" placeholder="Søk i titler eller tagger…" autocomplete="off">
|
||
<select id="docFilterStatus">
|
||
<option value="">Alle statuser</option>
|
||
<option value="ready">Klar</option>
|
||
<option value="pending">Venter</option>
|
||
<option value="processing">Behandler</option>
|
||
<option value="error">Feil</option>
|
||
</select>
|
||
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled>Slett valgte</button>
|
||
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;">+ Last opp</a>
|
||
</div>
|
||
|
||
<div id="docListWrap"><p class="dash-loading">Laster dokumenter…</p></div>
|
||
|
||
<div class="dash-pager" id="docPager" hidden>
|
||
<span id="docPagerLabel"></span>
|
||
<div class="dash-pager__actions">
|
||
<button class="dash-btn" id="docPagerPrev">← Forrige</button>
|
||
<button class="dash-btn" id="docPagerNext">Neste →</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
const api = window.DBN_DASHBOARD.apiBase;
|
||
const PAGE = 25;
|
||
|
||
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' };
|
||
|
||
const $wrap = document.getElementById('docListWrap');
|
||
const $pager = document.getElementById('docPager');
|
||
const $pl = document.getElementById('docPagerLabel');
|
||
const $prev = document.getElementById('docPagerPrev');
|
||
const $next = document.getElementById('docPagerNext');
|
||
const $bulk = document.getElementById('docBulkDelete');
|
||
const $fq = document.getElementById('docFilterQ');
|
||
const $fs = document.getElementById('docFilterStatus');
|
||
|
||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||
function fmtDate(s) {
|
||
if (!s) return '—';
|
||
try { return new Date(s.replace(' ', 'T') + 'Z').toLocaleDateString('nb-NO', { day:'numeric', month:'short', year:'numeric' }); }
|
||
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>';
|
||
}
|
||
|
||
function load() {
|
||
const qs = new URLSearchParams({ action:'list', offset:String(state.offset), limit:String(PAGE) });
|
||
if (state.q) qs.set('q', state.q);
|
||
if (state.status) qs.set('status', state.status);
|
||
|
||
$wrap.innerHTML = '<p class="dash-loading">Laster dokumenter…</p>';
|
||
fetch(api + '/documents.php?' + qs, { credentials:'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.ok) throw new Error(data.error?.message || 'Failed');
|
||
state.total = data.total;
|
||
render(data.documents || []);
|
||
})
|
||
.catch(err => {
|
||
$wrap.innerHTML = '<div class="dash-error">' + safe(err.message) + '</div>';
|
||
});
|
||
}
|
||
|
||
function render(docs) {
|
||
if (!docs.length) {
|
||
$wrap.innerHTML = '<div class="dash-empty"><span class="dash-empty__icon">📭</span>'
|
||
+ (state.q || state.status ? 'Ingen treff for valgt filter.'
|
||
: 'Ingen dokumenter ennå. <a href="/dashboard/upload.php">Last opp ditt første</a>.')
|
||
+ '</div>';
|
||
$pager.hidden = true;
|
||
return;
|
||
}
|
||
|
||
const table = document.createElement('table');
|
||
table.className = 'dash-doctable';
|
||
table.innerHTML =
|
||
'<thead><tr>'
|
||
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>'
|
||
+ '<th>Tittel</th><th>Kategori</th><th>Status</th>'
|
||
+ '<th>Passasjer</th><th>Lagt til</th>'
|
||
+ '</tr></thead>';
|
||
const tbody = document.createElement('tbody');
|
||
|
||
docs.forEach(doc => {
|
||
const tr = document.createElement('tr');
|
||
tr.dataset.id = String(doc.id);
|
||
tr.innerHTML =
|
||
'<td><input type="checkbox" class="doc-check" value="' + doc.id + '"' + (state.selected.has(doc.id) ? ' checked' : '') + '></td>'
|
||
+ '<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
|
||
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + (doc.tags ? ' · ' + safe(doc.tags) : '') + '</div>' : (doc.tags ? '<div class="dash-doctable__meta">' + safe(doc.tags) + '</div>' : ''))
|
||
+ '</td>'
|
||
+ '<td>' + safe(doc.category || '—') + '</td>'
|
||
+ '<td>' + statusPill(doc.status) + '</td>'
|
||
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
|
||
+ '<td>' + fmtDate(doc.created_at) + '</td>';
|
||
tr.addEventListener('click', (e) => {
|
||
if (e.target.matches('input[type="checkbox"]')) return;
|
||
location.href = '/dashboard/document.php?id=' + doc.id;
|
||
});
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
$wrap.innerHTML = '';
|
||
$wrap.appendChild(table);
|
||
|
||
// Wire selection
|
||
const all = document.getElementById('docSelectAll');
|
||
all.addEventListener('change', () => {
|
||
tbody.querySelectorAll('.doc-check').forEach(c => {
|
||
c.checked = all.checked;
|
||
const id = parseInt(c.value, 10);
|
||
if (all.checked) state.selected.add(id); else state.selected.delete(id);
|
||
});
|
||
updateBulk();
|
||
});
|
||
tbody.querySelectorAll('.doc-check').forEach(c => {
|
||
c.addEventListener('change', (e) => {
|
||
const id = parseInt(e.target.value, 10);
|
||
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
|
||
updateBulk();
|
||
});
|
||
});
|
||
|
||
const from = state.offset + 1;
|
||
const to = Math.min(state.offset + docs.length, state.total);
|
||
$pl.textContent = 'Viser ' + from + '–' + to + ' av ' + state.total;
|
||
$prev.disabled = state.offset === 0;
|
||
$next.disabled = state.offset + PAGE >= state.total;
|
||
$pager.hidden = false;
|
||
}
|
||
|
||
function updateBulk() {
|
||
$bulk.disabled = state.selected.size === 0;
|
||
$bulk.textContent = state.selected.size > 0 ? 'Slett valgte (' + state.selected.size + ')' : 'Slett valgte';
|
||
}
|
||
|
||
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); });
|
||
$next.addEventListener('click', () => { state.offset += PAGE; load(); });
|
||
|
||
let filterTimer = null;
|
||
$fq.addEventListener('input', () => {
|
||
clearTimeout(filterTimer);
|
||
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250);
|
||
});
|
||
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); });
|
||
|
||
$bulk.addEventListener('click', () => {
|
||
if (!state.selected.size) return;
|
||
if (!confirm('Slette ' + state.selected.size + ' dokumenter? Dette kan ikke angres.')) return;
|
||
const ids = Array.from(state.selected);
|
||
$bulk.disabled = true;
|
||
fetch(api + '/documents.php?action=delete', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.ok) throw new Error(data.error?.message || 'Slett feilet');
|
||
state.selected.clear();
|
||
updateBulk();
|
||
load();
|
||
})
|
||
.catch(err => alert('Feil: ' + err.message));
|
||
});
|
||
|
||
load();
|
||
})();
|
||
</script>
|
||
|
||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|