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:
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$dashboardPage = 'chat';
|
||||
$dashboardTitle = 'Spør korpuset';
|
||||
$dashboardLead = 'Still juridiske spørsmål. Svar streames med kildehenvisninger til ditt eget korpus og delt Do Better Norge-pakke.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<div class="disclaimer" role="note" style="margin-bottom:1rem;">
|
||||
⚖ Juridisk informasjon og forberedelsesstøtte — ikke endelig juridisk rådgivning. Bekreft alltid med advokat.
|
||||
</div>
|
||||
|
||||
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
|
||||
<div id="chatLog" class="chat-log" aria-live="polite">
|
||||
<div class="chat-empty">Still ditt første spørsmål. Det kan handle om barnerett, barnevern, EMD, arbeidsrett — alt som finnes i ditt korpus.</div>
|
||||
</div>
|
||||
|
||||
<form id="chatForm" class="chat-form" autocomplete="off">
|
||||
<textarea id="chatInput" rows="2" required placeholder="f.eks. «Hva sier barnevernloven § 4-12 om plassering uten samtykke?»"></textarea>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" id="chatSendBtn">Send</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chat-log { flex: 1; overflow-y: auto; padding: 0.25rem 0.25rem 1rem; display: flex; flex-direction: column; gap: 1rem; min-height: 40vh; max-height: 65vh; }
|
||||
.chat-empty { text-align: center; padding: 3rem 1rem; color: rgba(22, 19, 15, 0.5); font-style: italic; }
|
||||
.chat-msg { display: flex; flex-direction: column; gap: 0.4rem; max-width: 88%; }
|
||||
.chat-msg--user { align-self: flex-end; }
|
||||
.chat-msg--ai { align-self: flex-start; }
|
||||
.chat-bubble {
|
||||
padding: 0.85rem 1.05rem; border-radius: var(--dash-radius); line-height: 1.55; font-size: 0.95rem;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.chat-msg--user .chat-bubble { background: var(--dbn-blue); color: #fff; }
|
||||
.chat-msg--ai .chat-bubble { background: #fff; border: 1px solid var(--dbn-line); }
|
||||
.chat-sources { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.chat-source-chip {
|
||||
font-size: 0.78rem; padding: 0.2rem 0.55rem; border-radius: 999px;
|
||||
background: rgba(0, 32, 91, 0.07); color: var(--dbn-blue); text-decoration: none;
|
||||
border: 1px solid rgba(0, 32, 91, 0.15);
|
||||
}
|
||||
.chat-source-chip:hover { background: rgba(0, 32, 91, 0.13); }
|
||||
.chat-actions { display: flex; gap: 0.5rem; }
|
||||
.chat-actions button { font-size: 0.8rem; padding: 0.25rem 0.55rem; }
|
||||
.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 0.6rem; padding-top: 0.75rem; border-top: 1px solid var(--dbn-line); }
|
||||
.chat-form textarea {
|
||||
padding: 0.7rem 0.85rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
|
||||
font: inherit; resize: vertical; min-height: 3rem; max-height: 8rem; background: #fff;
|
||||
}
|
||||
.chat-meta { font-size: 0.72rem; color: rgba(22, 19, 15, 0.5); margin-top: 0.2rem; }
|
||||
.chat-thinking { font-style: italic; color: rgba(22, 19, 15, 0.5); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
const $log = document.getElementById('chatLog');
|
||||
const $form = document.getElementById('chatForm');
|
||||
const $input = document.getElementById('chatInput');
|
||||
const $send = document.getElementById('chatSendBtn');
|
||||
|
||||
const history = []; // [{role, content}]
|
||||
|
||||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||||
|
||||
function clearEmpty() {
|
||||
const empty = $log.querySelector('.chat-empty');
|
||||
if (empty) empty.remove();
|
||||
}
|
||||
function appendUser(text) {
|
||||
clearEmpty();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-msg chat-msg--user';
|
||||
wrap.innerHTML = '<div class="chat-bubble">' + safe(text) + '</div>';
|
||||
$log.appendChild(wrap);
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
}
|
||||
function appendAi() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-msg chat-msg--ai';
|
||||
wrap.innerHTML =
|
||||
'<div class="chat-bubble"><span class="chat-stream"></span><span class="chat-thinking">tenker…</span></div>'
|
||||
+ '<div class="chat-sources" hidden></div>'
|
||||
+ '<div class="chat-actions" hidden>'
|
||||
+ '<button class="dash-btn chat-save" type="button">💾 Lagre i korpus</button>'
|
||||
+ '<button class="dash-btn chat-copy" type="button">📋 Kopier</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="chat-meta" hidden></div>';
|
||||
$log.appendChild(wrap);
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function ask(question) {
|
||||
appendUser(question);
|
||||
history.push({ role: 'user', content: question });
|
||||
const aiNode = appendAi();
|
||||
const stream = aiNode.querySelector('.chat-stream');
|
||||
const thinking = aiNode.querySelector('.chat-thinking');
|
||||
const sources = aiNode.querySelector('.chat-sources');
|
||||
const actions = aiNode.querySelector('.chat-actions');
|
||||
const meta = aiNode.querySelector('.chat-meta');
|
||||
|
||||
$send.disabled = true;
|
||||
$input.disabled = true;
|
||||
|
||||
let answer = '';
|
||||
try {
|
||||
const resp = await fetch(api + '/chat-stream.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, history: history.slice(0, -1) }),
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let firstToken = true;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE frames: blocks separated by "\n\n"
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
||||
const frame = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
let evName = 'message';
|
||||
let data = '';
|
||||
frame.split('\n').forEach(line => {
|
||||
if (line.startsWith('event:')) evName = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
});
|
||||
if (!data) continue;
|
||||
let payload;
|
||||
try { payload = JSON.parse(data); } catch (_) { continue; }
|
||||
|
||||
if (evName === 'token') {
|
||||
if (firstToken) { thinking.remove(); firstToken = false; }
|
||||
answer += payload.t || '';
|
||||
stream.textContent = answer;
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
} else if (evName === 'done') {
|
||||
history.push({ role: 'assistant', content: answer });
|
||||
renderSources(sources, payload.sources || []);
|
||||
meta.hidden = false;
|
||||
meta.textContent =
|
||||
(payload.chunks_used || 0) + ' passasjer · '
|
||||
+ (payload.model || 'auto') + ' · '
|
||||
+ (payload.response_time_ms || 0) + ' ms';
|
||||
actions.hidden = false;
|
||||
wireActions(aiNode, question, answer);
|
||||
} else if (evName === 'fail') {
|
||||
thinking.textContent = '❌ ' + (payload.message || 'Feil');
|
||||
thinking.style.color = 'var(--dbn-red)';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
thinking.textContent = '❌ ' + err.message;
|
||||
thinking.style.color = 'var(--dbn-red)';
|
||||
} finally {
|
||||
$send.disabled = false;
|
||||
$input.disabled = false;
|
||||
$input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSources(container, sources) {
|
||||
if (!sources.length) return;
|
||||
container.hidden = false;
|
||||
container.innerHTML = sources.map(s => {
|
||||
const label = s.title + (s.section ? ' (§ ' + s.section + ')' : '');
|
||||
if (s.document_id > 0) {
|
||||
return '<a class="chat-source-chip" href="/dashboard/document.php?id=' + s.document_id + '">'
|
||||
+ safe(label) + '</a>';
|
||||
}
|
||||
if (s.source_url) {
|
||||
return '<a class="chat-source-chip" href="' + safe(s.source_url) + '" target="_blank" rel="noopener">'
|
||||
+ safe(label) + ' ↗</a>';
|
||||
}
|
||||
return '<span class="chat-source-chip">' + safe(label) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function wireActions(node, question, answer) {
|
||||
node.querySelector('.chat-copy').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(answer).then(() => {
|
||||
const btn = node.querySelector('.chat-copy');
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '✓ Kopiert';
|
||||
setTimeout(() => btn.textContent = orig, 1400);
|
||||
});
|
||||
});
|
||||
node.querySelector('.chat-save').addEventListener('click', () => {
|
||||
// Re-uses existing corpus-save.js dialog (loaded by layout footer)
|
||||
const dialog = document.getElementById('save-corpus-dialog');
|
||||
const titleField = document.getElementById('save-corpus-title');
|
||||
const tagsField = document.getElementById('save-corpus-tags');
|
||||
if (!dialog || !titleField) {
|
||||
alert('Lagre-dialog ikke tilgjengelig.');
|
||||
return;
|
||||
}
|
||||
titleField.value = question.slice(0, 80);
|
||||
tagsField.value = 'chat,answer';
|
||||
// Hand-off contract used by corpus-save.js: data-pending-content
|
||||
dialog.dataset.pendingContent = 'Q: ' + question + '\n\nA: ' + answer;
|
||||
dialog.dataset.pendingTool = 'dashboard-chat';
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
$form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const q = $input.value.trim();
|
||||
if (!q) return;
|
||||
$input.value = '';
|
||||
ask(q);
|
||||
});
|
||||
|
||||
$input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
$form.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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'; ?>
|
||||
@@ -0,0 +1,189 @@
|
||||
<?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'; ?>
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$dashboardPage = 'index';
|
||||
$dashboardTitle = 'Min korpus';
|
||||
$dashboardLead = 'Privat juridisk kunnskapsbase. Last opp, organiser, spør — alt holdes til din konto.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-kpis" id="dashKpis" aria-label="Corpus statistics">
|
||||
<div class="dash-kpi">
|
||||
<p class="dash-kpi__label">Dokumenter</p>
|
||||
<p class="dash-kpi__value" data-kpi="documents">—</p>
|
||||
<p class="dash-kpi__hint">av kvote</p>
|
||||
</div>
|
||||
<div class="dash-kpi">
|
||||
<p class="dash-kpi__label">Passasjer indeksert</p>
|
||||
<p class="dash-kpi__value" data-kpi="chunks">—</p>
|
||||
<p class="dash-kpi__hint">søkbare biter</p>
|
||||
</div>
|
||||
<div class="dash-kpi">
|
||||
<p class="dash-kpi__label">Klare</p>
|
||||
<p class="dash-kpi__value" data-kpi="ready">—</p>
|
||||
<p class="dash-kpi__hint">av totalt</p>
|
||||
</div>
|
||||
<div class="dash-kpi">
|
||||
<p class="dash-kpi__label">Siste opplasting</p>
|
||||
<p class="dash-kpi__value" data-kpi="last_upload" style="font-size: 1.05rem;">—</p>
|
||||
<p class="dash-kpi__hint">dato</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Kom i gang</h2>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem;">
|
||||
<a class="dash-btn dash-btn--primary" href="/dashboard/upload.php">📥 Last opp dokumenter</a>
|
||||
<a class="dash-btn" href="/dashboard/chat.php">💬 Still et juridisk spørsmål</a>
|
||||
<a class="dash-btn" href="/dashboard/documents.php">📚 Bla gjennom korpus</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Nylig aktivitet</h2>
|
||||
<div class="dash-card__actions">
|
||||
<a href="/dashboard/documents.php" class="dash-btn">Se alle →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashRecent" class="dash-loading">Laster…</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
|
||||
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 label = { ready: 'Klar', pending: 'Venter', processing: 'Behandler', error: 'Feil' }[status] || status;
|
||||
return '<span class="dash-status ' + cls + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
fetch(api + '/documents.php?action=list&limit=100', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.ok) throw new Error(data.error?.message || 'Failed');
|
||||
const docs = data.documents || [];
|
||||
const total = data.total;
|
||||
const chunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
|
||||
const ready = docs.filter(d => d.status === 'ready').length;
|
||||
const last = docs[0] ? docs[0].created_at : null;
|
||||
|
||||
document.querySelector('[data-kpi="documents"]').textContent = fmtNum(total);
|
||||
document.querySelector('[data-kpi="chunks"]').textContent = fmtNum(chunks);
|
||||
document.querySelector('[data-kpi="ready"]').textContent = ready + ' / ' + docs.length;
|
||||
document.querySelector('[data-kpi="last_upload"]').textContent = fmtDate(last);
|
||||
|
||||
const recent = document.getElementById('dashRecent');
|
||||
if (!docs.length) {
|
||||
recent.className = 'dash-empty';
|
||||
recent.innerHTML = '<span class="dash-empty__icon">📭</span>Ingen dokumenter ennå. '
|
||||
+ '<a href="/dashboard/upload.php">Last opp ditt første</a>.';
|
||||
return;
|
||||
}
|
||||
recent.className = '';
|
||||
recent.innerHTML = '';
|
||||
const table = document.createElement('table');
|
||||
table.className = 'dash-doctable';
|
||||
table.innerHTML = '<thead><tr><th>Tittel</th><th>Status</th><th>Passasjer</th><th>Lagt til</th></tr></thead>';
|
||||
const tbody = document.createElement('tbody');
|
||||
docs.slice(0, 8).forEach(doc => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.addEventListener('click', () => location.href = '/dashboard/document.php?id=' + doc.id);
|
||||
const safe = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
||||
tr.innerHTML =
|
||||
'<td><span class="dash-doctable__title">' + safe(doc.title) + '</span>'
|
||||
+ (doc.source_tool ? '<div class="dash-doctable__meta">via ' + safe(doc.source_tool) + '</div>' : '') + '</td>'
|
||||
+ '<td>' + statusPill(doc.status) + '</td>'
|
||||
+ '<td>' + fmtNum(doc.chunk_count) + '</td>'
|
||||
+ '<td>' + fmtDate(doc.created_at) + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
recent.appendChild(table);
|
||||
})
|
||||
.catch(err => {
|
||||
const recent = document.getElementById('dashRecent');
|
||||
recent.className = 'dash-error';
|
||||
recent.textContent = 'Kunne ikke laste: ' + err.message;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$dashboardPage = 'settings';
|
||||
$dashboardTitle = 'Innstillinger';
|
||||
$dashboardLead = 'Innstillinger for ditt private korpus.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Konto</h2>
|
||||
</div>
|
||||
<dl style="display:grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; font-size:0.92rem;">
|
||||
<dt style="color:rgba(22,19,15,0.55);">Klient-ID</dt>
|
||||
<dd><code id="setClientId">—</code></dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Korpus-ID</dt>
|
||||
<dd><code id="setCorpusId">—</code></dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Bruker-ID</dt>
|
||||
<dd><code id="setUserId">—</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>RAG-pipeline</h2>
|
||||
</div>
|
||||
<dl style="display:grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; font-size:0.92rem;">
|
||||
<dt style="color:rgba(22,19,15,0.55);">Chunking</dt>
|
||||
<dd>600 ord pr. passasje, 75 ords overlapp, heading-aware</dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Embedding-modell</dt>
|
||||
<dd><code>nomic-embed-text</code> (768-dim) via LiteLLM på Colin</dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Vector DB</dt>
|
||||
<dd><code>bnl_client_chunks</code> i Qdrant (Colin Docker)</dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Søkemetode</dt>
|
||||
<dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Graf-database</dt>
|
||||
<dd><code>bnl_legal</code> i FalkorDB (Colin) — siterings-edges</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Personvern</h2>
|
||||
</div>
|
||||
<p style="margin-top:0; max-width:64ch; line-height:1.6;">
|
||||
Alt du laster opp eller lagrer her holdes til din konto. Andre brukere kan ikke se eller søke i dine dokumenter.
|
||||
Felles-pakken <code>family-legal</code> (~220K passasjer av norsk lovverk og rettspraksis) er delt og brukes
|
||||
for å berike svar med autoritative kilder, men du eier alt du selv legger inn.
|
||||
</p>
|
||||
<p style="max-width:64ch; line-height:1.6;">
|
||||
Slett enkelt-dokumenter fra <a href="/dashboard/documents.php">Dokumenter</a>. Trenger du å slette hele
|
||||
kontoen, kontakt support.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const d = window.DBN_DASHBOARD || {};
|
||||
document.getElementById('setClientId').textContent = d.clientId || '—';
|
||||
document.getElementById('setCorpusId').textContent = d.corpusId || '—';
|
||||
document.getElementById('setUserId').textContent = d.clientUserId || '—';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$dashboardPage = 'upload';
|
||||
$dashboardTitle = 'Last opp dokumenter';
|
||||
$dashboardLead = 'Last opp PDF, DOCX, eller TXT — eller lim inn tekst eller en URL. Innholdet chunkes, embedes og indekseres i din private korpus.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Velg kilde</h2>
|
||||
<div class="dash-card__actions">
|
||||
<button class="dash-btn upload-mode is-active" data-mode="file">Fil</button>
|
||||
<button class="dash-btn upload-mode" data-mode="text">Lim inn tekst</button>
|
||||
<button class="dash-btn upload-mode" data-mode="url">URL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="upFileForm" class="upload-form" enctype="multipart/form-data" style="display:grid; gap:0.85rem;">
|
||||
<label class="upload-drop" id="upDrop">
|
||||
<input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt" hidden>
|
||||
<span class="upload-drop__icon">📥</span>
|
||||
<strong>Slipp filen her, eller klikk for å bla</strong>
|
||||
<small>PDF · DOCX · TXT · maks 8 MB · automatisk OCR for skannede PDF-er</small>
|
||||
</label>
|
||||
<div class="upload-meta">
|
||||
<label>Tittel<input name="title" placeholder="Auto fra filnavn om tom"></label>
|
||||
<label>Kategori<input name="category" placeholder="f.eks. barnevern, familierett"></label>
|
||||
<label>Tagger<input name="tags" placeholder="komma,separert,liste"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Last opp og indekser</button>
|
||||
</form>
|
||||
|
||||
<form id="upTextForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
||||
<label>Tittel<input name="title" required placeholder="Gi notatet en tittel"></label>
|
||||
<label>Innhold<textarea name="content" rows="12" required placeholder="Lim inn tekst her — minst 30 tegn"></textarea></label>
|
||||
<div class="upload-meta">
|
||||
<label>Kategori<input name="category" placeholder="f.eks. notat"></label>
|
||||
<label>Tagger<input name="tags"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Lagre i korpus</button>
|
||||
</form>
|
||||
|
||||
<form id="upUrlForm" class="upload-form" hidden style="display:grid; gap:0.85rem;">
|
||||
<label>URL<input name="url" type="url" required placeholder="https://lovdata.no/dokument/…"></label>
|
||||
<label>Tittel<input name="title" placeholder="Tom = bruker URL"></label>
|
||||
<div class="upload-meta">
|
||||
<label>Kategori<input name="category"></label>
|
||||
<label>Tagger<input name="tags"></label>
|
||||
<label>Språk<input name="language" value="no" maxlength="10"></label>
|
||||
</div>
|
||||
<button type="submit" class="dash-btn dash-btn--primary" style="justify-self:start;">Hent og indekser</button>
|
||||
<small style="color:rgba(22,19,15,0.55);">URLer kjøres i bakgrunnen — sjekk «Dokumenter» for status.</small>
|
||||
</form>
|
||||
|
||||
<div id="upStatus" class="upload-status" hidden></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.upload-mode { background: #fff; }
|
||||
.upload-mode.is-active { background: var(--dbn-blue); color: #fff; border-color: var(--dbn-blue); }
|
||||
.upload-drop {
|
||||
display: grid; gap: 0.4rem; place-items: center; text-align: center;
|
||||
padding: 2.5rem 1rem; border: 2px dashed var(--dbn-line); border-radius: var(--dash-radius);
|
||||
cursor: pointer; background: #fcfaf5; transition: border-color 150ms, background 150ms;
|
||||
}
|
||||
.upload-drop:hover, .upload-drop.is-drag { border-color: var(--dbn-blue); background: #fff; }
|
||||
.upload-drop__icon { font-size: 2.5rem; opacity: 0.5; }
|
||||
.upload-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.85rem; }
|
||||
.upload-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: rgba(22, 19, 15, 0.7); }
|
||||
.upload-form input, .upload-form textarea {
|
||||
padding: 0.55rem 0.7rem; border: 1px solid var(--dbn-line); border-radius: var(--dash-radius-sm);
|
||||
font: inherit; background: #fff; width: 100%;
|
||||
}
|
||||
.upload-form textarea { font-family: "IBM Plex Sans", system-ui, sans-serif; resize: vertical; min-height: 8rem; }
|
||||
.upload-status {
|
||||
margin-top: 1.25rem; padding: 0.85rem 1rem; border-radius: var(--dash-radius-sm);
|
||||
background: #fcfaf5; border: 1px solid var(--dbn-line); font-size: 0.9rem;
|
||||
}
|
||||
.upload-status--ok { border-color: rgba(15, 118, 110, 0.4); background: rgba(15, 118, 110, 0.05); }
|
||||
.upload-status--err { border-color: rgba(186, 12, 47, 0.4); background: rgba(186, 12, 47, 0.05); color: var(--dbn-red); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
|
||||
const forms = {
|
||||
file: document.getElementById('upFileForm'),
|
||||
text: document.getElementById('upTextForm'),
|
||||
url: document.getElementById('upUrlForm'),
|
||||
};
|
||||
const status = document.getElementById('upStatus');
|
||||
|
||||
document.querySelectorAll('.upload-mode').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.upload-mode').forEach(b => b.classList.remove('is-active'));
|
||||
btn.classList.add('is-active');
|
||||
const mode = btn.dataset.mode;
|
||||
for (const m in forms) forms[m].hidden = (m !== mode);
|
||||
status.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Drag-drop wiring for file
|
||||
const drop = document.getElementById('upDrop');
|
||||
const fileInput = document.getElementById('upFile');
|
||||
if (drop && fileInput) {
|
||||
drop.addEventListener('click', () => fileInput.click());
|
||||
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => {
|
||||
e.preventDefault(); drop.classList.add('is-drag');
|
||||
}));
|
||||
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => {
|
||||
e.preventDefault(); drop.classList.remove('is-drag');
|
||||
}));
|
||||
drop.addEventListener('drop', e => {
|
||||
if (e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
drop.querySelector('strong').textContent = e.dataTransfer.files[0].name;
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) {
|
||||
drop.querySelector('strong').textContent = fileInput.files[0].name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(html, kind) {
|
||||
status.hidden = false;
|
||||
status.className = 'upload-status' + (kind ? ' upload-status--' + kind : '');
|
||||
status.innerHTML = html;
|
||||
}
|
||||
|
||||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||||
|
||||
forms.file.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(forms.file);
|
||||
if (!fileInput.files.length) { setStatus('Velg en fil først.', 'err'); return; }
|
||||
setStatus('Laster opp og indekserer…');
|
||||
fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
forms.text.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const payload = { kind: 'text' };
|
||||
new FormData(forms.text).forEach((v, k) => payload[k] = v);
|
||||
setStatus('Indekserer…');
|
||||
fetch(api + '/upload.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
forms.url.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const payload = { kind: 'url' };
|
||||
new FormData(forms.url).forEach((v, k) => payload[k] = v);
|
||||
setStatus('Køer URL for henting…');
|
||||
fetch(api + '/upload.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(handleResult).catch(err => setStatus('Feil: ' + safe(err.message), 'err'));
|
||||
});
|
||||
|
||||
function handleResult(data) {
|
||||
if (data.status === 'ready') {
|
||||
setStatus('✅ Indeksert! '
|
||||
+ (data.chunks || 0) + ' passasjer lagt til. '
|
||||
+ '<a href="/dashboard/document.php?id=' + data.document_id + '">Åpne dokument</a> · '
|
||||
+ '<a href="/dashboard/documents.php">Se alle</a>', 'ok');
|
||||
['file', 'text', 'url'].forEach(m => forms[m].reset());
|
||||
if (drop) drop.querySelector('strong').textContent = 'Slipp filen her, eller klikk for å bla';
|
||||
} else if (data.status === 'pending') {
|
||||
setStatus('📥 Lagt i kø. '
|
||||
+ '<a href="/dashboard/documents.php">Følg fremdriften i Dokumenter</a>.', 'ok');
|
||||
pollUntilDone(data.document_id);
|
||||
} else if (data.status === 'error') {
|
||||
setStatus('❌ ' + safe(data.error?.message || 'Indeksering feilet.'), 'err');
|
||||
} else if (!data.ok) {
|
||||
setStatus('❌ ' + safe(data.error?.message || 'Opplasting feilet.'), 'err');
|
||||
} else {
|
||||
setStatus('Uventet svar.', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function pollUntilDone(docId) {
|
||||
let tries = 0;
|
||||
const tick = () => {
|
||||
if (++tries > 40) return;
|
||||
fetch(api + '/ingest-status.php?ids=' + docId, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const s = (data.statuses || []).find(x => x.id === docId);
|
||||
if (!s) return;
|
||||
if (s.status === 'ready') {
|
||||
setStatus('✅ Bakgrunnsjobb ferdig. '
|
||||
+ s.chunk_count + ' passasjer indeksert. '
|
||||
+ '<a href="/dashboard/document.php?id=' + docId + '">Åpne dokument</a>', 'ok');
|
||||
return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
setStatus('❌ ' + safe(s.error_message || 'Bakgrunnsjobb feilet.'), 'err');
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
})
|
||||
.catch(() => setTimeout(tick, 4000));
|
||||
};
|
||||
setTimeout(tick, 3000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
Reference in New Issue
Block a user