468 lines
22 KiB
HTML
468 lines
22 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %}
|
|
{% block extra_head %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=9" />
|
|
<style>
|
|
.doc-neo-shell { --hm-cyan: #00e5ff; --hm-border: rgba(0, 229, 255, 0.12); --hm-panel: #0d1219; }
|
|
.doc-neo-shell .page-header h1 { font-size: 1.55rem; }
|
|
.doc-dash-filters { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:1rem; }
|
|
.doc-neo-kpi-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:1rem; margin-bottom:1rem; }
|
|
@media (max-width: 900px) { .doc-neo-kpi-row { grid-template-columns: 1fr 1fr; } }
|
|
.doc-neo-kpi {
|
|
background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98));
|
|
border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.1rem 0.65rem;
|
|
}
|
|
.doc-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; color: #94a3b8; }
|
|
.doc-neo-kpi-value { font-size: 1.65rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0; }
|
|
.doc-chart-grid { display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:1rem; }
|
|
@media (max-width: 800px) { .doc-chart-grid { grid-template-columns: 1fr; } }
|
|
.doc-chart-panel .hm-chart-wrap { height: 130px; }
|
|
.photo-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:1rem; }
|
|
.photo-card { border:1px solid rgba(0,229,255,0.12); border-radius:12px; overflow:hidden; background:rgba(13,18,25,0.95); }
|
|
.photo-card img { width:100%; height:140px; object-fit:cover; background:#0a0f1a; }
|
|
.photo-card-body { padding:0.6rem; font-size:0.78rem; }
|
|
.detection-wrap { position:relative; display:inline-block; max-width:100%; }
|
|
.detection-wrap canvas { position:absolute; left:0; top:0; pointer-events:none; }
|
|
</style>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="doc-neo-shell" x-data="documentsDash()" x-init="init()">
|
|
<header class="page-header herman-hero" style="margin-bottom:1rem">
|
|
<h1>Documents & Foto's <span class="live-badge">Neo</span></h1>
|
|
<p class="hero-sub page-tip">NAS corpus · sentiment · woorden · foto OCR — interactief dashboard</p>
|
|
</header>
|
|
|
|
<div class="doc-neo-kpi-row">
|
|
<div class="doc-neo-kpi clickable-kpi" @click="filterSentiment=''">
|
|
<div class="doc-neo-kpi-label">Documenten</div>
|
|
<div class="doc-neo-kpi-value" x-text="filteredDocs.length"></div>
|
|
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</div>
|
|
<div class="doc-neo-kpi clickable-kpi">
|
|
<div class="doc-neo-kpi-label">Totaal woorden</div>
|
|
<div class="doc-neo-kpi-value">{{ summary.total_words }}</div>
|
|
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</div>
|
|
<div class="doc-neo-kpi clickable-kpi">
|
|
<div class="doc-neo-kpi-label">Foto's in DB</div>
|
|
<div class="doc-neo-kpi-value" x-text="photos.length"></div>
|
|
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</div>
|
|
<div class="doc-neo-kpi clickable-kpi">
|
|
<div class="doc-neo-kpi-label">Gem. sentiment</div>
|
|
<div class="doc-neo-kpi-value">{{ summary.avg_sentiment }}</div>
|
|
<div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="doc-dash-filters panel">
|
|
<input type="search" class="form-input" style="min-width:180px" placeholder="Zoek bestand..." x-model="filterQuery" @input="applyFilters()" />
|
|
<select class="form-input" x-model="filterSentiment" @change="applyFilters()">
|
|
<option value="">Alle sentiment</option>
|
|
<option value="positive">Positief</option>
|
|
<option value="neutral">Neutraal</option>
|
|
<option value="negative">Negatief</option>
|
|
</select>
|
|
<select class="form-input" x-model="filterType" @change="applyFilters()">
|
|
<option value="">Alle types</option>
|
|
<option value="presentation">Presentatie (pptx)</option>
|
|
<template x-for="t in docTypes" :key="t"><option :value="t" x-text="t"></option></template>
|
|
</select>
|
|
<label style="font-size:0.8rem;color:var(--text-muted)"><input type="checkbox" x-model="liveRefresh" @change="toggleLive()" /> Live refresh (15s)</label>
|
|
<button class="btn btn-secondary btn-sm" @click="loadPhotos()">↻ Foto's</button>
|
|
<button class="btn btn-secondary btn-sm" @click="refreshShare()">↻ Share scan</button>
|
|
</div>
|
|
|
|
<div class="doc-chart-grid">
|
|
<section class="hm-chart-panel doc-chart-panel">
|
|
<div class="hm-chart-head"><h4>Top woorden</h4><small>klik = filter</small></div>
|
|
<div class="hm-chart-wrap"><canvas id="wordsChart"></canvas></div>
|
|
<div id="viz-words-alt" class="viz-alt"></div>
|
|
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</section>
|
|
<section class="hm-chart-panel doc-chart-panel">
|
|
<div class="hm-chart-head"><h4>Sentiment</h4><small>klik = filter</small></div>
|
|
<div class="hm-chart-wrap"><canvas id="sentimentChart"></canvas></div>
|
|
<div id="viz-sentiment-alt" class="viz-alt"></div>
|
|
<div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel" style="margin-top:1rem" x-data="wordSearch()" x-init="init()">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap">
|
|
<h2 style="margin:0">Woord zoeken</h2>
|
|
<div style="display:flex;gap:.5rem">
|
|
<input type="search" x-model="query" @keydown.enter="search()" placeholder="Zoek lemma..." class="form-input" style="min-width:220px" />
|
|
<label style="display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:var(--text-muted)">
|
|
<input type="checkbox" x-model="includeStopwords" @change="search()" /> stopwoorden
|
|
</label>
|
|
<button class="btn btn-primary" @click="search()">Zoek</button>
|
|
</div>
|
|
</div>
|
|
<table class="data-table" style="margin-top:1rem">
|
|
<thead><tr><th>Lemma</th><th>Token</th><th>Totaal</th><th>Documenten</th></tr></thead>
|
|
<tbody>
|
|
<template x-if="loading"><tr><td colspan="4">Laden...</td></tr></template>
|
|
<template x-for="row in results" :key="row.lemma">
|
|
<tr class="clickable-row" @click="$root.filterQuery=row.lemma; $root.applyFilters()">
|
|
<td x-text="row.lemma"></td>
|
|
<td x-text="row.token || row.sample_token || '—'"></td>
|
|
<td x-text="row.total_count"></td>
|
|
<td x-text="row.document_count || row.doc_count"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section class="panel" style="margin-top:1rem">
|
|
<h2>Documenten op NAS <span class="muted" x-text="'(' + filteredDocs.length + ')'"></span></h2>
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr><th>Bestand</th><th>Pad</th><th>Type</th><th>Woorden</th><th>Sentiment</th><th>Score</th><th>Geanalyseerd</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="doc in filteredDocs" :key="doc.storage_path">
|
|
<tr>
|
|
<td x-text="doc.filename"></td>
|
|
<td class="muted" style="font-size:0.75rem" x-text="doc.storage_path"></td>
|
|
<td x-text="doc.doc_type"></td>
|
|
<td x-text="doc.word_count"></td>
|
|
<td><span class="badge" :class="'badge-' + (doc.sentiment_label || 'neutral')" x-text="doc.sentiment_label || '—'"></span></td>
|
|
<td x-text="doc.sentiment_compound != null ? Number(doc.sentiment_compound).toFixed(3) : '—'"></td>
|
|
<td x-text="(doc.analyzed_at || doc.modified_at || '').substring(0,16)"></td>
|
|
</tr>
|
|
</template>
|
|
<tr x-show="!filteredDocs.length"><td colspan="7" class="empty-state">Geen documenten voor dit filter.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section class="panel" style="margin-top:1rem">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem">
|
|
<h2 style="margin:0">Foto's — OCR & detectie</h2>
|
|
<div style="display:flex;gap:0.5rem">
|
|
<input type="file" accept="image/*" @change="uploadPhoto($event)" class="form-input" style="max-width:220px" />
|
|
<button class="btn btn-secondary btn-sm" @click="loadNasImages()">NAS share foto's</button>
|
|
</div>
|
|
</div>
|
|
<p class="page-tip">Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).</p>
|
|
<div class="photo-grid" style="margin-top:1rem">
|
|
<template x-for="p in photos" :key="p.id">
|
|
<div class="photo-card clickable-row" @click="openPhoto(p.id)">
|
|
<img :src="'/api/admin/photos/' + p.id + '/image.jpg'" :alt="p.filename || 'foto'" loading="lazy" />
|
|
<div class="photo-card-body">
|
|
<strong x-text="'#' + p.id + ' ' + (p.source || '')"></strong><br/>
|
|
<span x-text="(p.ocr_preview || '').substring(0,80)"></span><br/>
|
|
<small x-text="(p.box_count || 0) + ' boxes · ' + (p.item_count || 0) + ' items'"></small>
|
|
<button class="btn btn-sm btn-secondary" style="margin-top:0.35rem" @click.stop="exportPhoto(p.id)">📥 Export</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<p class="empty-state" x-show="!photos.length">Nog geen foto's — upload hier of stuur via Telegram.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" x-show="activePhoto" style="margin-top:1rem">
|
|
<h2>Detectie overlay — foto #<span x-text="activePhoto"></span></h2>
|
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem" x-show="activePhoto">
|
|
<select class="form-input" x-model="linkProjectId" style="max-width:200px">
|
|
<option value="">Koppel aan project…</option>
|
|
<template x-for="p in cockpitProjects" :key="p.id">
|
|
<option :value="p.id" x-text="p.name"></option>
|
|
</template>
|
|
</select>
|
|
<button class="btn btn-sm btn-primary" @click="savePhotoToProject()">Opslaan in project</button>
|
|
<a href="/projects" class="btn btn-sm">Alle projecten →</a>
|
|
</div>
|
|
<div class="detection-wrap">
|
|
<img id="detectionImg" :src="activePhoto ? '/api/admin/photos/' + activePhoto + '/image.jpg' : ''" style="max-width:100%;border-radius:8px" @load="drawDetections()" />
|
|
<canvas id="detectionCanvas"></canvas>
|
|
</div>
|
|
<pre class="browser-preview-text" style="margin-top:0.75rem;max-height:160px" x-text="activeOcr"></pre>
|
|
<table class="data-table" x-show="activeItems.length" style="margin-top:0.5rem;font-size:0.78rem">
|
|
<thead><tr><th>Geëxtraheerd</th><th>Gewicht</th><th>Prijs</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="(it,i) in activeItems" :key="i">
|
|
<tr><td x-text="it.raw"></td><td x-text="it.weight||'—'"></td><td x-text="it.price||'—'"></td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const topWords = {{ top_words | tojson }};
|
|
const summary = {{ summary | tojson }};
|
|
const allDocuments = {{ documents | tojson }};
|
|
let shareMerged = false;
|
|
let wordsChartInst = null;
|
|
let sentimentChartInst = null;
|
|
|
|
function documentsDash() {
|
|
return {
|
|
filterQuery: '',
|
|
filterSentiment: '',
|
|
filterType: '',
|
|
filteredDocs: allDocuments,
|
|
docTypes: [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))],
|
|
photos: [],
|
|
activePhoto: null,
|
|
activeOcr: '',
|
|
activeItems: [],
|
|
detections: [],
|
|
linkProjectId: '',
|
|
cockpitProjects: [],
|
|
liveRefresh: false,
|
|
liveTimer: null,
|
|
|
|
init() {
|
|
this.applyFilters();
|
|
this.renderCharts(window.__vizMode || 'neo-bars');
|
|
this.loadPhotos();
|
|
this.refreshShare(false);
|
|
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
|
|
document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
|
|
},
|
|
async savePhotoToProject() {
|
|
if (!this.activePhoto || !this.linkProjectId) return Cockpit.toast('Kies een project', 'error');
|
|
try {
|
|
await Cockpit.api('/api/projects/photos/' + this.activePhoto + '/link', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ project_id: parseInt(this.linkProjectId, 10) }),
|
|
});
|
|
Cockpit.toast('Foto gekoppeld aan project', 'success');
|
|
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
|
},
|
|
|
|
mergeShareItems(items) {
|
|
const byPath = new Map(allDocuments.map(d => [d.storage_path, d]));
|
|
(items || []).forEach(f => {
|
|
const path = f.path || f.storage_path;
|
|
if (!path) return;
|
|
if (byPath.has(path)) {
|
|
const ex = byPath.get(path);
|
|
if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
|
|
return;
|
|
}
|
|
const row = {
|
|
filename: f.filename || path.split('/').pop(),
|
|
storage_path: path,
|
|
doc_type: f.doc_type || 'general',
|
|
word_count: f.word_count || 0,
|
|
sentiment_label: f.sentiment_label || 'neutral',
|
|
sentiment_compound: f.sentiment_compound || 0,
|
|
analyzed_at: f.analyzed_at || '',
|
|
modified_at: f.modified_at || '',
|
|
from_share: !f.analyzed_at,
|
|
};
|
|
allDocuments.push(row);
|
|
byPath.set(path, row);
|
|
});
|
|
this.docTypes = [...new Set(allDocuments.map(d => d.doc_type).filter(Boolean))];
|
|
this.applyFilters();
|
|
},
|
|
|
|
async refreshShare(showToast) {
|
|
try {
|
|
if (showToast !== false) Cockpit.toast('Share scannen…', 'info');
|
|
await fetch('/api/admin/documents/trigger-scan', { method: 'POST' });
|
|
const r = await fetch('/api/admin/documents/share-files?limit=300');
|
|
const data = await r.json();
|
|
if (data.ok) {
|
|
this.mergeShareItems(data.items || []);
|
|
if (showToast !== false) Cockpit.toast((data.total || 0) + ' bestanden op share', 'success');
|
|
}
|
|
} catch (e) {
|
|
if (showToast !== false) Cockpit.toast('Share scan mislukt', 'error');
|
|
}
|
|
},
|
|
|
|
applyFilters() {
|
|
const q = this.filterQuery.toLowerCase();
|
|
this.filteredDocs = allDocuments.filter(d => {
|
|
if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
|
|
if (this.filterType && d.doc_type !== this.filterType) return false;
|
|
if (q && !(d.filename || '').toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
},
|
|
|
|
renderCharts(mode) {
|
|
mode = mode || window.__vizMode || 'neo-bars';
|
|
if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
|
|
if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
|
|
const dash = this;
|
|
const wordLabels = topWords.slice(0, 10).map(w => w.lemma);
|
|
const wordValues = topWords.slice(0, 10).map(w => w.total_count);
|
|
const sentLabels = ['Positief', 'Neutraal', 'Negatief'];
|
|
const sentValues = [summary.positive, summary.neutral, summary.negative];
|
|
if (window.VizEngine && (mode === 'neo-equalizer' || mode === 'neo-cards' || mode === 'neo-table')) {
|
|
VizEngine.renderWidget(mode, {
|
|
canvas: document.getElementById('wordsChart'),
|
|
altContainer: document.getElementById('viz-words-alt'),
|
|
key: 'doc-words',
|
|
labels: wordLabels, values: wordValues,
|
|
rows: wordLabels.map((l, i) => ({ label: l, value: wordValues[i], raw: wordValues[i] })),
|
|
eqClass: 'hm-eq-cyan',
|
|
});
|
|
VizEngine.renderWidget(mode, {
|
|
canvas: document.getElementById('sentimentChart'),
|
|
altContainer: document.getElementById('viz-sentiment-alt'),
|
|
key: 'doc-sentiment',
|
|
labels: sentLabels, values: sentValues,
|
|
rows: sentLabels.map((l, i) => ({ label: l, value: sentValues[i], raw: sentValues[i] })),
|
|
eqClass: 'hm-eq-purple',
|
|
});
|
|
return;
|
|
}
|
|
document.getElementById('viz-words-alt').innerHTML = '';
|
|
document.getElementById('viz-sentiment-alt').innerHTML = '';
|
|
document.getElementById('wordsChart').style.display = '';
|
|
document.getElementById('sentimentChart').style.display = '';
|
|
wordsChartInst = new Chart(document.getElementById('wordsChart'), {
|
|
type: mode === 'neo-rings' ? 'polarArea' : 'bar',
|
|
data: {
|
|
labels: topWords.slice(0, 10).map(w => w.lemma),
|
|
datasets: [{ label: 'Aantal', data: topWords.slice(0, 10).map(w => w.total_count),
|
|
backgroundColor: 'rgba(0,229,255,0.75)', borderWidth: 0, borderRadius: 6 }]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
onClick: (_, els) => { if (els[0]) { dash.filterQuery = topWords[els[0].index].lemma; dash.applyFilters(); } },
|
|
scales: { y: { beginAtZero: true, ticks: { color: '#9aa4b2', font: { size: 9 } } },
|
|
x: { ticks: { color: '#9aa4b2', font: { size: 9 } } } }
|
|
}
|
|
});
|
|
sentimentChartInst = new Chart(document.getElementById('sentimentChart'), {
|
|
type: mode === 'neo-bars' ? 'doughnut' : 'doughnut',
|
|
data: {
|
|
labels: ['Positief', 'Neutraal', 'Negatief'],
|
|
datasets: [{ data: [summary.positive, summary.neutral, summary.negative],
|
|
backgroundColor: ['#4ade80', '#64748b', '#fb7185'], borderWidth: 0 }]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { labels: { color: '#cbd5e1', font: { size: 9 } } } },
|
|
onClick: (_, els) => {
|
|
if (!els[0]) return;
|
|
dash.filterSentiment = ['positive','neutral','negative'][els[0].index];
|
|
dash.applyFilters();
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
async loadPhotos() {
|
|
try {
|
|
const r = await Cockpit.api('/photos?limit=40');
|
|
this.photos = r.photos || [];
|
|
} catch (e) {}
|
|
},
|
|
|
|
toggleLive() {
|
|
if (this.liveTimer) clearInterval(this.liveTimer);
|
|
if (this.liveRefresh) {
|
|
this.liveTimer = setInterval(() => {
|
|
this.loadPhotos();
|
|
this.refreshShare(false);
|
|
}, 15000);
|
|
}
|
|
},
|
|
|
|
async openPhoto(id) {
|
|
this.activePhoto = id;
|
|
try {
|
|
const r = await Cockpit.api('/photos/' + id + '/detections');
|
|
this.detections = r.detections || [];
|
|
this.activeItems = r.extracted_items || [];
|
|
this.activeOcr = r.ocr_text || '';
|
|
setTimeout(() => this.drawDetections(), 200);
|
|
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
|
},
|
|
|
|
drawDetections() {
|
|
const img = document.getElementById('detectionImg');
|
|
const canvas = document.getElementById('detectionCanvas');
|
|
if (!img || !canvas || !this.detections.length) return;
|
|
canvas.width = img.clientWidth;
|
|
canvas.height = img.clientHeight;
|
|
const sx = img.clientWidth / (img.naturalWidth || 1);
|
|
const sy = img.clientHeight / (img.naturalHeight || 1);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.strokeStyle = '#e8a838';
|
|
ctx.lineWidth = 1;
|
|
ctx.font = '9px sans-serif';
|
|
this.detections.slice(0, 150).forEach(b => {
|
|
ctx.strokeRect(b.x * sx, b.y * sy, b.w * sx, b.h * sy);
|
|
if (b.text && b.text.length < 12) ctx.fillText(b.text, b.x * sx, b.y * sy - 2);
|
|
});
|
|
},
|
|
|
|
exportPhoto(id) {
|
|
const a = document.createElement('a');
|
|
a.href = '/api/admin/photos/' + id + '/image.jpg';
|
|
a.download = 'foodlinkk-photo-' + id + '.jpg';
|
|
a.click();
|
|
},
|
|
|
|
async uploadPhoto(ev) {
|
|
const file = ev.target.files && ev.target.files[0];
|
|
if (!file) return;
|
|
const b64 = await new Promise((res, rej) => {
|
|
const r = new FileReader();
|
|
r.onload = () => res(r.result.split(',')[1]);
|
|
r.onerror = rej;
|
|
r.readAsDataURL(file);
|
|
});
|
|
try {
|
|
await Cockpit.api('/photos/analyze', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
|
|
});
|
|
Cockpit.toast('Foto geanalyseerd en opgeslagen', 'success');
|
|
await this.loadPhotos();
|
|
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
|
ev.target.value = '';
|
|
},
|
|
|
|
async loadNasImages() {
|
|
try {
|
|
const r = await fetch('/api/admin/documents/nas-images?limit=10');
|
|
const data = await r.json();
|
|
if (!data.items || !data.items.length) return Cockpit.toast('Geen afbeeldingen in NAS share', 'info');
|
|
Cockpit.toast(data.items.length + ' NAS afbeeldingen gevonden — analyseer via upload of Telegram', 'success');
|
|
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
|
},
|
|
};
|
|
}
|
|
|
|
function wordSearch() {
|
|
return {
|
|
query: '', includeStopwords: false, results: topWords.slice(0, 20), loading: false,
|
|
async search() {
|
|
this.loading = true;
|
|
try {
|
|
const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
|
|
if (this.query.trim()) params.set('q', this.query.trim());
|
|
const res = await fetch('/api/admin/documents/words?' + params);
|
|
this.results = (await res.json()).items || [];
|
|
} catch (e) { Cockpit.toast('Woord zoeken mislukt', 'error'); }
|
|
finally { this.loading = false; }
|
|
},
|
|
init() { this.search(); }
|
|
};
|
|
}
|
|
</script>
|
|
<style>
|
|
.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
|
.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
|
.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .2rem .55rem; border-radius: 999px; font-size: .75rem; }
|
|
</style>
|
|
{% endblock %}
|