Full DMS: folders + ACLs, versioning, trash, bulk ops, preview, smart folders
Rebuild the dashboard as a Drive-style document management system on top of the existing CaveauAI hybrid RAG pipeline. Backend: - 5 migrations (versions, trash soft-delete, saved searches, categories, audit) - DMS helpers (folder ACL walker, disk storage, audit, version snapshot, XLSX/PPTX/HTML/CSV/MD extractors) - New APIs: folders, document-versions, trash, bulk, preview, saved-searches, categories, diagnostics - Extended APIs: documents (folder_id, soft-delete, ACL filter, sort), upload (9 file types, version-collision detection with replace/new/keep-both, disk persistence), chat-stream (folder scoping + graph related-documents) - 30-day trash purge cron with Qdrant + disk + graph cleanup Frontend: - Drive-style two-pane browser with folder tree, drag-drop, bulk-action bar, right-click context menu, multi-select - New pages: folders (tree + per-folder ACL editor), trash (restore/purge) - Extended pages: upload (folder picker, version-collision modal, 9 file type chips), document (Preview/Versions/Permissions tabs with PDF.js + mammoth.js + audio), index (DMS KPIs + activity feed), settings (live diagnostics ping MariaDB/Qdrant/LiteLLM/FalkorDB/disk), chat (folder scope chips + related-authorities chips) - New CSS (dms.css) + JS bundle (dms.js) exposing window.DBN_DMS - Sidebar nav adds Folders + Trash items All routes return HTTP 200 in local smoke test; all 32 files lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+44
-1
@@ -11,6 +11,22 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
</div>
|
||||
|
||||
<section class="dash-card" style="display:flex; flex-direction:column; min-height:60vh;">
|
||||
<div class="dms-filters" style="margin-bottom:8px;">
|
||||
<label style="font-size:13px;display:inline-flex;gap:6px;align-items:center;">
|
||||
📁 <span>Scope:</span>
|
||||
<select id="chatFolderScope">
|
||||
<option value="all">All folders (whole tenant)</option>
|
||||
<option value="unassigned">Unassigned only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
||||
<input type="checkbox" id="chatIncludeSub" checked> Include subfolders
|
||||
</label>
|
||||
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
||||
<input type="checkbox" id="chatIncludeRelated" checked> Show related authorities (graph)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="chatLog" class="chat-log" aria-live="polite">
|
||||
<div class="chat-empty" id="chatEmptyMsg"></div>
|
||||
</div>
|
||||
@@ -113,10 +129,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
|
||||
let answer = '';
|
||||
try {
|
||||
const folderScope = document.getElementById('chatFolderScope');
|
||||
const includeSub = document.getElementById('chatIncludeSub');
|
||||
const includeRel = document.getElementById('chatIncludeRelated');
|
||||
const body = { question, history: history.slice(0, -1) };
|
||||
if (folderScope && folderScope.value && folderScope.value !== 'all') {
|
||||
body.folder_id = folderScope.value;
|
||||
body.include_subfolders = includeSub && includeSub.checked;
|
||||
}
|
||||
if (includeRel && includeRel.checked) body.include_related = true;
|
||||
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) }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
|
||||
|
||||
@@ -152,6 +177,7 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
} else if (evName === 'done') {
|
||||
history.push({ role: 'assistant', content: answer });
|
||||
renderSources(sources, payload.sources || []);
|
||||
renderRelated(aiNode, payload.related_documents || []);
|
||||
const chunksTmpl = (I18N.chat_passages_meta || '{n} passages').replace('{n}', payload.chunks_used || 0);
|
||||
meta.hidden = false;
|
||||
meta.textContent = chunksTmpl + ' · ' + (payload.model || 'auto') + ' · ' + (payload.response_time_ms || 0) + ' ms';
|
||||
@@ -190,6 +216,23 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRelated(node, related) {
|
||||
if (!related || !related.length) return;
|
||||
let rel = node.querySelector('.chat-related');
|
||||
if (!rel) {
|
||||
rel = document.createElement('div');
|
||||
rel.className = 'chat-related';
|
||||
rel.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem;';
|
||||
const sources = node.querySelector('.chat-sources');
|
||||
sources.parentNode.insertBefore(rel, sources.nextSibling);
|
||||
}
|
||||
const label = '<small style="opacity:.6;width:100%;display:block;">↳ Related authorities (graph):</small>';
|
||||
rel.innerHTML = label + related.slice(0, 6).map(r =>
|
||||
'<a class="chat-source-chip" href="/dashboard/document.php?id=' + r.doc_id + '" style="background:rgba(184,138,44,0.16);color:#6c5212;border-color:rgba(184,138,44,0.4)">'
|
||||
+ safe(r.title || ('doc #' + r.doc_id)) + ' · ' + safe(r.shared) + '⋆</a>'
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
function wireActions(node, question, answer) {
|
||||
node.querySelector('.chat-copy').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(answer).then(() => {
|
||||
|
||||
+130
-10
@@ -77,15 +77,17 @@ $docId = (int)($_GET['id'] ?? 0);
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
|
||||
+ '<nav class="dash-tabs" role="tablist">'
|
||||
+ '<button class="dash-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>'
|
||||
+ '<button class="dash-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + ' (' + fmtNum(doc.chunk_count) + ')</button>'
|
||||
+ '<button class="dash-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>'
|
||||
+ '<button class="dash-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>'
|
||||
+ '<nav class="dash-tabs dms-tabs" role="tablist">'
|
||||
+ '<button class="dash-tab dms-tab is-active" data-tab="preview" role="tab">' + (I18N.tab_preview || 'Preview') + '</button>'
|
||||
+ '<button class="dash-tab dms-tab" data-tab="chunks" role="tab">' + (I18N.tab_chunks || 'Passages') + '<span class="dms-tab__pill">' + fmtNum(doc.chunk_count) + '</span></button>'
|
||||
+ '<button class="dash-tab dms-tab" data-tab="related" role="tab">' + (I18N.tab_related || 'Related') + '</button>'
|
||||
+ '<button class="dash-tab dms-tab" data-tab="versions" role="tab">Versions<span class="dms-tab__pill">v' + (doc.current_version || 1) + '</span></button>'
|
||||
+ '<button class="dash-tab dms-tab" data-tab="permissions" role="tab">Access</button>'
|
||||
+ '<button class="dash-tab dms-tab" data-tab="edit" role="tab">' + (I18N.tab_edit || 'Edit') + '</button>'
|
||||
+ '</nav>'
|
||||
|
||||
+ '<div class="dash-tab-panel is-active" data-panel="preview">'
|
||||
+ '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>'
|
||||
+ '<div class="dash-tab-panel dms-tab-panel is-active" data-panel="preview">'
|
||||
+ renderPreviewPanel(doc)
|
||||
+ '</div>'
|
||||
|
||||
+ '<div class="dash-tab-panel" data-panel="chunks">'
|
||||
@@ -97,12 +99,22 @@ $docId = (int)($_GET['id'] ?? 0);
|
||||
: '<div class="dash-empty">' + (I18N.no_chunks || 'No passages indexed yet.') + '</div>')
|
||||
+ '</div>'
|
||||
|
||||
+ '<div class="dash-tab-panel" data-panel="related">'
|
||||
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="related">'
|
||||
+ '<div class="dash-loading" id="relatedLoading">' + (I18N.loading_related || 'Loading related authorities from the graph…') + '</div>'
|
||||
+ '<div class="dash-related" id="relatedList" hidden></div>'
|
||||
+ '</div>'
|
||||
|
||||
+ '<div class="dash-tab-panel" data-panel="edit">'
|
||||
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="versions">'
|
||||
+ '<div class="dms-loading" id="versionsLoading">Loading versions…</div>'
|
||||
+ '<div id="versionsList" hidden></div>'
|
||||
+ '</div>'
|
||||
|
||||
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="permissions">'
|
||||
+ '<div class="dms-loading" id="permLoading">Loading access info…</div>'
|
||||
+ '<div id="permPanel" hidden></div>'
|
||||
+ '</div>'
|
||||
|
||||
+ '<div class="dash-tab-panel dms-tab-panel" data-panel="edit">'
|
||||
+ '<form id="docEditForm" style="display:grid; gap:0.85rem; max-width:560px;">'
|
||||
+ '<label>' + (I18N.field_title || 'Title') + '<input name="title" value="' + safe(doc.title) + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
|
||||
+ '<label>' + (I18N.field_category || 'Category') + '<input name="category" value="' + safe(doc.category || '') + '" style="width:100%;padding:0.5rem;border:1px solid var(--dbn-line);border-radius:8px;"></label>'
|
||||
@@ -117,11 +129,117 @@ $docId = (int)($_GET['id'] ?? 0);
|
||||
+ '</section>';
|
||||
|
||||
root.innerHTML = html;
|
||||
window._dmsCurrentDoc = doc;
|
||||
wireTabs();
|
||||
wireDelete();
|
||||
wireEdit();
|
||||
}
|
||||
|
||||
function renderPreviewPanel(doc) {
|
||||
const ext = (doc.original_filename || '').split('.').pop().toLowerCase();
|
||||
const previewUrl = api + '/preview.php?id=' + doc.id;
|
||||
if (doc.has_storage && ext === 'pdf') {
|
||||
return '<iframe class="dms-preview-frame" src="' + previewUrl + '" title="PDF preview"></iframe>';
|
||||
}
|
||||
if (doc.has_storage && ['png','jpg','jpeg','webp','gif'].indexOf(ext) >= 0) {
|
||||
return '<div style="text-align:center;padding:12px"><img src="' + previewUrl + '" style="max-width:100%;max-height:70vh;border-radius:10px;border:1px solid var(--dms-stroke)"></div>';
|
||||
}
|
||||
if (doc.has_storage && ['mp3','wav','m4a','ogg','flac','webm'].indexOf(ext) >= 0) {
|
||||
return '<audio class="dms-preview-audio" controls src="' + previewUrl + '"></audio>'
|
||||
+ '<details><summary>Transcript</summary><div class="dash-preview">' + safe(doc.content || '') + '</div></details>';
|
||||
}
|
||||
if (doc.has_storage && ext === 'docx') {
|
||||
return '<div id="docxPreview" class="dms-preview-frame" style="padding:16px;overflow:auto;background:#fff"></div>'
|
||||
+ '<script src="https://cdn.jsdelivr.net/npm/mammoth@1.6.0/mammoth.browser.min.js"><' + '/script>'
|
||||
+ '<script>setTimeout(function(){fetch("' + previewUrl + '",{credentials:"same-origin"}).then(r=>r.arrayBuffer()).then(buf=>mammoth.convertToHtml({arrayBuffer:buf})).then(res=>{document.getElementById("docxPreview").innerHTML=res.value;}).catch(e=>{document.getElementById("docxPreview").textContent="Preview failed: "+e.message;});},10);<' + '/script>';
|
||||
}
|
||||
// Fallback: text preview from extracted content
|
||||
return '<div class="dash-preview">' + safe(doc.content || (I18N.content_empty || '(empty)')) + '</div>'
|
||||
+ (doc.has_storage ? '<p style="margin-top:8px"><a href="' + previewUrl + '&download=1" class="dash-btn">⬇ Download original</a></p>' : '');
|
||||
}
|
||||
|
||||
let versionsLoaded = false;
|
||||
function loadVersions() {
|
||||
if (versionsLoaded) return;
|
||||
versionsLoaded = true;
|
||||
const wrap = document.getElementById('versionsList');
|
||||
const loading = document.getElementById('versionsLoading');
|
||||
fetch(api + '/document-versions.php?action=list&document_id=' + docId, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
loading.hidden = true; wrap.hidden = false;
|
||||
const versions = data.versions || [];
|
||||
const cur = window._dmsCurrentDoc || {};
|
||||
let html = '<div class="dms-version dms-version--current">'
|
||||
+ '<div class="dms-version__num">v' + (cur.current_version || 1) + '</div>'
|
||||
+ '<div><div class="dms-version__title">' + safe(cur.title) + '</div>'
|
||||
+ '<div class="dms-version__meta">Current · ' + fmtDate(cur.updated_at || cur.created_at) + '</div></div>'
|
||||
+ '<div class="dms-version__actions"></div></div>';
|
||||
if (!versions.length) {
|
||||
html += '<div class="dash-empty">No previous versions.</div>';
|
||||
} else {
|
||||
html += versions.map(v =>
|
||||
'<div class="dms-version">'
|
||||
+ '<div class="dms-version__num">v' + v.version_number + '</div>'
|
||||
+ '<div><div class="dms-version__title">' + safe(v.title) + '</div>'
|
||||
+ '<div class="dms-version__meta">' + fmtDate(v.created_at)
|
||||
+ (v.uploaded_email ? ' · ' + safe(v.uploaded_email) : '')
|
||||
+ (v.notes ? ' · ' + safe(v.notes) : '') + '</div></div>'
|
||||
+ '<div class="dms-version__actions">'
|
||||
+ '<button class="dash-btn" data-restore="' + v.id + '">Restore</button> '
|
||||
+ '<button class="dash-btn dash-btn--danger" data-del-ver="' + v.id + '">✕</button>'
|
||||
+ '</div></div>'
|
||||
).join('');
|
||||
}
|
||||
wrap.innerHTML = html;
|
||||
wrap.querySelectorAll('[data-restore]').forEach(b => b.addEventListener('click', () => restoreVersion(Number(b.dataset.restore))));
|
||||
wrap.querySelectorAll('[data-del-ver]').forEach(b => b.addEventListener('click', () => deleteVersion(Number(b.dataset['delVer']))));
|
||||
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
|
||||
}
|
||||
function restoreVersion(vid) {
|
||||
if (!confirm('Restore this version? Current version will be archived first.')) return;
|
||||
fetch(api + '/document-versions.php?action=restore', {
|
||||
method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ document_id: docId, version_id: vid })
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (!d.ok) throw new Error(d.message || 'Restore failed');
|
||||
location.reload();
|
||||
}).catch(e => alert(e.message));
|
||||
}
|
||||
function deleteVersion(vid) {
|
||||
if (!confirm('Delete this version permanently?')) return;
|
||||
fetch(api + '/document-versions.php?action=delete', {
|
||||
method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ version_id: vid })
|
||||
}).then(r => r.json()).then(d => { versionsLoaded = false; loadVersions(); })
|
||||
.catch(e => alert(e.message));
|
||||
}
|
||||
|
||||
let permLoaded = false;
|
||||
function loadPermissions() {
|
||||
if (permLoaded) return;
|
||||
permLoaded = true;
|
||||
const loading = document.getElementById('permLoading');
|
||||
const wrap = document.getElementById('permPanel');
|
||||
fetch(api + '/documents.php?action=get&id=' + docId, { credentials: 'same-origin' })
|
||||
.then(r => r.json()).then(d => {
|
||||
loading.hidden = true; wrap.hidden = false;
|
||||
const p = d.permissions || {};
|
||||
const fid = (d.document && d.document.folder_id) || null;
|
||||
let html = '<div class="dms-diag"><div class="dms-diag__row"><div class="dms-diag__label">Read</div><div></div><div>' + (p.can_read ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--err">denied</span>') + '</div></div>'
|
||||
+ '<div class="dms-diag__row"><div class="dms-diag__label">Write</div><div></div><div>' + (p.can_write ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">read-only</span>') + '</div></div>'
|
||||
+ '<div class="dms-diag__row"><div class="dms-diag__label">Manage folder</div><div></div><div>' + (p.can_manage ? '<span class="dms-diag__status dms-diag__status--ok">allowed</span>' : '<span class="dms-diag__status dms-diag__status--warn">no</span>') + '</div></div></div>';
|
||||
if (fid) {
|
||||
html += '<p style="margin-top:12px"><a class="dash-btn" href="/dashboard/folders.php#' + fid + '">Manage access on parent folder →</a></p>';
|
||||
} else {
|
||||
html += '<p style="margin-top:12px;color:rgba(22,19,15,0.6)">This document is unassigned — move it into a folder to use folder ACLs.</p>';
|
||||
}
|
||||
wrap.innerHTML = html;
|
||||
}).catch(e => { loading.textContent = 'Error: ' + e.message; });
|
||||
}
|
||||
|
||||
function wireTabs() {
|
||||
const tabs = root.querySelectorAll('.dash-tab');
|
||||
const panels = root.querySelectorAll('.dash-tab-panel');
|
||||
@@ -132,6 +250,8 @@ $docId = (int)($_GET['id'] ?? 0);
|
||||
const panel = root.querySelector('[data-panel="' + t.dataset.tab + '"]');
|
||||
if (panel) panel.classList.add('is-active');
|
||||
if (t.dataset.tab === 'related') loadRelated();
|
||||
if (t.dataset.tab === 'versions') loadVersions();
|
||||
if (t.dataset.tab === 'permissions') loadPermissions();
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -168,7 +288,7 @@ $docId = (int)($_GET['id'] ?? 0);
|
||||
const btn = document.getElementById('docDelete');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
if (!confirm(I18N.delete_doc_confirm || 'Delete this document permanently?')) return;
|
||||
if (!confirm('Move to trash? You can restore within 30 days.')) return;
|
||||
btn.disabled = true;
|
||||
fetch(api + '/documents.php?action=delete', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
|
||||
+177
-172
@@ -6,27 +6,48 @@ $dashboardTitle = dbnToolsT('dash_title_docs', dbnToolsCurrentLanguage());
|
||||
$dashboardLead = dbnToolsT('dash_lead_docs', dbnToolsCurrentLanguage());
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dash-card">
|
||||
<div class="dash-filters">
|
||||
<input type="search" id="docFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off">
|
||||
<select id="docFilterStatus">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
|
||||
<option value="ready" id="optReady"></option>
|
||||
<option value="pending" id="optPending"></option>
|
||||
<option value="processing" id="optProcessing"></option>
|
||||
<option value="error" id="optError"></option>
|
||||
</select>
|
||||
<button id="docBulkDelete" class="dash-btn dash-btn--danger" disabled><?= htmlspecialchars(dbnToolsT('dash_delete_selected', $uiLang)) ?></button>
|
||||
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary" style="margin-left: auto;"><?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
|
||||
</div>
|
||||
<section class="dms-shell">
|
||||
<aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
|
||||
<div class="dms-loading"></div>
|
||||
</aside>
|
||||
|
||||
<div id="docListWrap"><p class="dash-loading"></p></div>
|
||||
<div class="dms-main">
|
||||
<div class="dms-toolbar">
|
||||
<div class="dms-toolbar__crumbs" id="dmsCrumbs">
|
||||
<a href="#" data-folder-id="all">🗂 <?= htmlspecialchars(dbnToolsT('dash_dms_all_files', $uiLang) ?: 'All files') ?></a>
|
||||
</div>
|
||||
<div class="dms-toolbar__actions">
|
||||
<button class="dash-btn" id="dmsSaveSearch" type="button">★ <?= htmlspecialchars(dbnToolsT('dash_dms_save_view', $uiLang) ?: 'Save view') ?></button>
|
||||
<a href="/dashboard/upload.php" class="dash-btn dash-btn--primary">+ <?= htmlspecialchars(dbnToolsT('dash_upload_btn_short', $uiLang)) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-pager" id="docPager" hidden>
|
||||
<span id="docPagerLabel"></span>
|
||||
<div class="dash-pager__actions">
|
||||
<button class="dash-btn" id="docPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
|
||||
<button class="dash-btn" id="docPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
|
||||
<div class="dms-filters">
|
||||
<input type="search" id="dmsFilterQ" placeholder="<?= htmlspecialchars(dbnToolsT('dash_filter_q_ph', $uiLang)) ?>" autocomplete="off">
|
||||
<select id="dmsFilterStatus">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_filter_all_status', $uiLang)) ?></option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<select id="dmsFilterCategory">
|
||||
<option value=""><?= htmlspecialchars(dbnToolsT('dash_dms_all_categories', $uiLang) ?: 'All categories') ?></option>
|
||||
</select>
|
||||
<label style="font-size:13px;display:inline-flex;align-items:center;gap:4px;">
|
||||
<input type="checkbox" id="dmsIncludeSub" checked>
|
||||
<?= htmlspecialchars(dbnToolsT('dash_dms_include_sub', $uiLang) ?: 'Include subfolders') ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="dmsListWrap"><div class="dms-loading"></div></div>
|
||||
|
||||
<div class="dash-pager" id="dmsPager" hidden>
|
||||
<span id="dmsPagerLabel"></span>
|
||||
<div class="dash-pager__actions">
|
||||
<button class="dash-btn" id="dmsPagerPrev"><?= htmlspecialchars(dbnToolsT('dash_prev', $uiLang)) ?></button>
|
||||
<button class="dash-btn" id="dmsPagerNext"><?= htmlspecialchars(dbnToolsT('dash_next', $uiLang)) ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -34,177 +55,161 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const I18N = window.DBN_I18N || {};
|
||||
const api = window.DBN_DASHBOARD.apiBase;
|
||||
const loc = I18N.locale || 'en-GB';
|
||||
const PAGE = 25;
|
||||
if (!window.DBN_DMS) { console.error('dms.js missing'); return; }
|
||||
const DMS = window.DBN_DMS;
|
||||
|
||||
const optReady = document.getElementById('optReady');
|
||||
const optPend = document.getElementById('optPending');
|
||||
const optProc = document.getElementById('optProcessing');
|
||||
const optErr = document.getElementById('optError');
|
||||
if (optReady) optReady.textContent = I18N.status_ready || 'Ready';
|
||||
if (optPend) optPend.textContent = I18N.status_pending || 'Pending';
|
||||
if (optProc) optProc.textContent = I18N.status_processing || 'Processing';
|
||||
if (optErr) optErr.textContent = I18N.status_error || 'Error';
|
||||
const $tree = document.getElementById('dmsTree');
|
||||
const $list = document.getElementById('dmsListWrap');
|
||||
const $crumbs = document.getElementById('dmsCrumbs');
|
||||
const $pager = document.getElementById('dmsPager');
|
||||
const $pl = document.getElementById('dmsPagerLabel');
|
||||
const $prev = document.getElementById('dmsPagerPrev');
|
||||
const $next = document.getElementById('dmsPagerNext');
|
||||
const $fq = document.getElementById('dmsFilterQ');
|
||||
const $fs = document.getElementById('dmsFilterStatus');
|
||||
const $fc = document.getElementById('dmsFilterCategory');
|
||||
const $sub = document.getElementById('dmsIncludeSub');
|
||||
const $save = document.getElementById('dmsSaveSearch');
|
||||
|
||||
const state = { offset: 0, total: 0, selected: new Set(), q: '', status: '' };
|
||||
let debounce;
|
||||
|
||||
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(loc, { day:'numeric', month:'short', year:'numeric' }); }
|
||||
catch (_) { return s; }
|
||||
}
|
||||
function fmtNum(n) { return n == null ? '—' : Number(n).toLocaleString(loc); }
|
||||
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: I18N.status_ready || 'Ready', pending: I18N.status_pending || 'Pending',
|
||||
processing: I18N.status_processing || 'Processing', error: I18N.status_error || 'Error',
|
||||
}[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">' + (I18N.loading_docs || 'Loading documents…') + '</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
|
||||
? (I18N.empty_filter || 'No results for selected filter.')
|
||||
: (I18N.empty_docs || 'No documents yet.') + ' <a href="/dashboard/upload.php">' + (I18N.empty_docs_link || 'Upload your first') + '</a>.')
|
||||
+ '</div>';
|
||||
$pager.hidden = true;
|
||||
return;
|
||||
async function loadAll() {
|
||||
$list.innerHTML = '<div class="dms-loading"></div>';
|
||||
try {
|
||||
await DMS.Tree.load();
|
||||
DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId });
|
||||
await DMS.Smart.load();
|
||||
await refreshCategories();
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'dash-doctable';
|
||||
table.innerHTML =
|
||||
'<thead><tr>'
|
||||
+ '<th style="width:36px;"><input type="checkbox" id="docSelectAll"></th>'
|
||||
+ '<th>' + (I18N.th_title || 'Title') + '</th>'
|
||||
+ '<th>' + (I18N.th_category || 'Category') + '</th>'
|
||||
+ '<th>' + (I18N.th_status || 'Status') + '</th>'
|
||||
+ '<th>' + (I18N.th_chunks || 'Passages') + '</th>'
|
||||
+ '<th>' + (I18N.th_added || 'Added') + '</th>'
|
||||
+ '</tr></thead>';
|
||||
const tbody = document.createElement('tbody');
|
||||
async function refreshCategories() {
|
||||
try {
|
||||
const data = await DMS.api('/categories.php?action=list');
|
||||
const opts = ['<option value="">All categories</option>'].concat(
|
||||
(data.categories || []).map(c => '<option value="' + DMS.safe(c.slug) + '">' + DMS.safe(c.label) + ' (' + c.doc_count + ')</option>')
|
||||
);
|
||||
const prev = $fc.value;
|
||||
$fc.innerHTML = opts.join('');
|
||||
$fc.value = prev;
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
|
||||
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;
|
||||
async function refreshList() {
|
||||
try {
|
||||
const data = await DMS.List.load();
|
||||
DMS.List.render($list);
|
||||
updateCrumbs(data.folder);
|
||||
updatePager(data);
|
||||
} catch (e) {
|
||||
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCrumbs(folder) {
|
||||
let html = '<a href="#" data-folder-id="all">🗂 All files</a>';
|
||||
if (folder && folder.breadcrumb) {
|
||||
folder.breadcrumb.forEach((b, i) => {
|
||||
const isLast = i === folder.breadcrumb.length - 1;
|
||||
html += '<span class="dms-toolbar__crumb-sep">›</span>';
|
||||
if (isLast) html += '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>';
|
||||
else html += '<a href="#" data-folder-id="' + b.id + '">' + DMS.safe(b.name) + '</a>';
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
$wrap.innerHTML = '';
|
||||
$wrap.appendChild(table);
|
||||
} else if (DMS.List.state.folderId === 'unassigned') {
|
||||
html += '<span class="dms-toolbar__crumb-sep">›</span><span class="dms-toolbar__crumb--current">Unassigned</span>';
|
||||
}
|
||||
$crumbs.innerHTML = html;
|
||||
}
|
||||
|
||||
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);
|
||||
const tmpl = I18N.pager_showing || 'Showing {from}–{to} of {total}';
|
||||
$pl.textContent = tmpl.replace('{from}', from).replace('{to}', to).replace('{total}', state.total);
|
||||
$prev.disabled = state.offset === 0;
|
||||
$next.disabled = state.offset + PAGE >= state.total;
|
||||
function updatePager(data) {
|
||||
const total = data.total || 0;
|
||||
const limit = DMS.List.state.limit;
|
||||
const offset = DMS.List.state.offset;
|
||||
if (total <= limit) { $pager.hidden = true; return; }
|
||||
$pager.hidden = false;
|
||||
$pl.textContent = (offset + 1) + '–' + Math.min(total, offset + limit) + ' / ' + total;
|
||||
$prev.disabled = offset === 0;
|
||||
$next.disabled = offset + limit >= total;
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
$bulk.disabled = state.selected.size === 0;
|
||||
$bulk.textContent = state.selected.size > 0
|
||||
? (I18N.delete_n_selected || 'Delete selected ({n})').replace('{n}', state.selected.size)
|
||||
: (I18N.delete_selected || 'Delete selected');
|
||||
}
|
||||
document.addEventListener('dms:folder-changed', e => {
|
||||
const id = e.detail.folderId;
|
||||
if (id === 'trash') { window.location.href = '/dashboard/trash.php'; return; }
|
||||
DMS.List.state.folderId = id;
|
||||
DMS.List.state.offset = 0;
|
||||
DMS.List.state.selected.clear();
|
||||
DMS.Tree.state.activeFolderId = id;
|
||||
DMS.Tree.render($tree, { activeFolderId: id });
|
||||
refreshList();
|
||||
});
|
||||
document.addEventListener('dms:reload-required', () => {
|
||||
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
|
||||
refreshList();
|
||||
});
|
||||
document.addEventListener('dms:reload-tree', () => {
|
||||
DMS.Tree.load().then(() => DMS.Tree.render($tree, { activeFolderId: DMS.List.state.folderId }));
|
||||
});
|
||||
document.addEventListener('dms:apply-smart', e => {
|
||||
const q = e.detail || {};
|
||||
if ('q' in q) $fq.value = q.q || '';
|
||||
if ('status' in q) $fs.value = q.status || '';
|
||||
if ('category' in q) $fc.value = q.category || '';
|
||||
if ('folder_id' in q) DMS.List.state.folderId = String(q.folder_id == null ? 'all' : q.folder_id);
|
||||
if ('include_subfolders' in q) $sub.checked = !!q.include_subfolders;
|
||||
DMS.List.state.q = $fq.value;
|
||||
DMS.List.state.status = $fs.value;
|
||||
DMS.List.state.category = $fc.value;
|
||||
DMS.List.state.includeSubfolders = $sub.checked;
|
||||
DMS.List.state.offset = 0;
|
||||
refreshList();
|
||||
});
|
||||
|
||||
$prev.addEventListener('click', () => { state.offset = Math.max(0, state.offset - PAGE); load(); });
|
||||
$next.addEventListener('click', () => { state.offset += PAGE; load(); });
|
||||
$crumbs.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[data-folder-id]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
DMS.List.state.folderId = a.dataset.folderId;
|
||||
DMS.List.state.offset = 0;
|
||||
DMS.Tree.state.activeFolderId = a.dataset.folderId;
|
||||
DMS.Tree.render($tree, { activeFolderId: a.dataset.folderId });
|
||||
refreshList();
|
||||
});
|
||||
|
||||
let filterTimer = null;
|
||||
$fq.addEventListener('input', () => {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => { state.q = $fq.value.trim(); state.offset = 0; load(); }, 250);
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
DMS.List.state.q = $fq.value.trim();
|
||||
DMS.List.state.offset = 0;
|
||||
refreshList();
|
||||
}, 200);
|
||||
});
|
||||
$fs.addEventListener('change', () => { state.status = $fs.value; state.offset = 0; load(); });
|
||||
$fs.addEventListener('change', () => { DMS.List.state.status = $fs.value; DMS.List.state.offset = 0; refreshList(); });
|
||||
$fc.addEventListener('change', () => { DMS.List.state.category = $fc.value; DMS.List.state.offset = 0; refreshList(); });
|
||||
$sub.addEventListener('change', () => { DMS.List.state.includeSubfolders = $sub.checked; refreshList(); });
|
||||
|
||||
$bulk.addEventListener('click', () => {
|
||||
if (!state.selected.size) return;
|
||||
const msg = (I18N.delete_docs_confirm || 'Delete {n} documents? This cannot be undone.').replace('{n}', state.selected.size);
|
||||
if (!confirm(msg)) 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 || 'Delete failed');
|
||||
state.selected.clear();
|
||||
updateBulk();
|
||||
load();
|
||||
})
|
||||
.catch(err => alert(err.message));
|
||||
$prev.addEventListener('click', () => { DMS.List.state.offset = Math.max(0, DMS.List.state.offset - DMS.List.state.limit); refreshList(); });
|
||||
$next.addEventListener('click', () => { DMS.List.state.offset += DMS.List.state.limit; refreshList(); });
|
||||
|
||||
$save.addEventListener('click', () => {
|
||||
DMS.Smart.save({
|
||||
q: $fq.value, status: $fs.value, category: $fc.value,
|
||||
folder_id: DMS.List.state.folderId,
|
||||
include_subfolders: $sub.checked,
|
||||
});
|
||||
});
|
||||
|
||||
load();
|
||||
DMS.installDropAnywhereUpload({ getFolderId: () => {
|
||||
const id = DMS.List.state.folderId;
|
||||
return (id && id !== 'all' && id !== 'unassigned' && id !== 'trash') ? id : null;
|
||||
}});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('folder')) DMS.List.state.folderId = params.get('folder');
|
||||
|
||||
loadAll();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$dashboardPage = 'folders';
|
||||
$dashboardTitle = dbnToolsT('dash_title_folders', dbnToolsCurrentLanguage()) ?: 'Folders';
|
||||
$dashboardLead = dbnToolsT('dash_lead_folders', dbnToolsCurrentLanguage()) ?: 'Organise documents and set per-folder access.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dms-shell">
|
||||
<aside class="dms-tree" id="dmsTree" aria-label="Folder tree">
|
||||
<div class="dms-loading"></div>
|
||||
</aside>
|
||||
<div class="dms-main">
|
||||
<div class="dms-toolbar">
|
||||
<div class="dms-toolbar__crumbs" id="folderCrumbs"><span>Pick a folder on the left</span></div>
|
||||
<div class="dms-toolbar__actions">
|
||||
<button class="dash-btn dash-btn--primary" id="folderNewBtn" type="button">+ New folder</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="folderDetail">
|
||||
<div class="dms-list__empty">
|
||||
<strong>Select a folder</strong>
|
||||
<span>Use the tree on the left, or create a new folder.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const DMS = window.DBN_DMS;
|
||||
const $tree = document.getElementById('dmsTree');
|
||||
const $detail = document.getElementById('folderDetail');
|
||||
const $crumbs = document.getElementById('folderCrumbs');
|
||||
let selectedFolderId = null;
|
||||
|
||||
async function loadTree() {
|
||||
await DMS.Tree.load();
|
||||
DMS.Tree.render($tree, { activeFolderId: selectedFolderId });
|
||||
}
|
||||
|
||||
async function showDetail(folderId) {
|
||||
if (folderId === 'all' || folderId === 'unassigned' || folderId === 'trash') {
|
||||
$detail.innerHTML = '<div class="dms-list__empty"><strong>System folder</strong><span>Cannot configure access for system folders.</span></div>';
|
||||
return;
|
||||
}
|
||||
selectedFolderId = folderId;
|
||||
$detail.innerHTML = '<div class="dms-loading"></div>';
|
||||
try {
|
||||
const [perms, crumbsData] = await Promise.all([
|
||||
DMS.api('/folders.php?action=list_permissions&folder_id=' + folderId),
|
||||
DMS.api('/folders.php?action=get_breadcrumb&folder_id=' + folderId),
|
||||
]);
|
||||
$crumbs.innerHTML = (crumbsData.breadcrumb || []).map((b, i, a) =>
|
||||
(i ? '<span class="dms-toolbar__crumb-sep">›</span>' : '')
|
||||
+ (i === a.length - 1
|
||||
? '<span class="dms-toolbar__crumb--current">' + DMS.safe(b.name) + '</span>'
|
||||
: '<span>' + DMS.safe(b.name) + '</span>')
|
||||
).join('');
|
||||
|
||||
const rows = (perms.permissions || []).map(p => {
|
||||
const who = p.user_id
|
||||
? '👤 ' + DMS.safe(p.user_email || ('user #' + p.user_id))
|
||||
: '🛡 role ≥ ' + DMS.safe(p.min_role);
|
||||
return '<div class="dms-perm-row">' +
|
||||
'<div>' + who + '</div>' +
|
||||
'<div>' + (p.can_read ? '✓' : '—') + ' read</div>' +
|
||||
'<div>' + (p.can_write ? '✓' : '—') + ' write</div>' +
|
||||
'<div>' + (p.can_manage ? '✓' : '—') + ' manage</div>' +
|
||||
'<div><button class="dms-list__more" data-pid="' + p.id + '" type="button" aria-label="Remove">✕</button></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
$detail.innerHTML =
|
||||
'<div class="dms-toolbar"><div class="dms-toolbar__crumbs"><strong>Access control</strong></div>' +
|
||||
'<div class="dms-toolbar__actions">' +
|
||||
'<button class="dash-btn" type="button" id="folderRename">Rename</button>' +
|
||||
'<button class="dash-btn dash-btn--danger" type="button" id="folderDelete">Delete</button>' +
|
||||
'<button class="dash-btn dash-btn--primary" type="button" id="folderAddAcl">+ Add rule</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="dms-list">' +
|
||||
(rows ||
|
||||
'<div class="dms-list__empty"><strong>Open to all in tenant</strong>' +
|
||||
'<span>No rules set — add one to restrict access.</span></div>') +
|
||||
(rows ? '' : '') +
|
||||
'</div>';
|
||||
|
||||
$detail.querySelector('#folderRename').addEventListener('click', () => {
|
||||
DMS.folderModal.open({ folderId, name: crumbsData.breadcrumb.slice(-1)[0]?.name || '' });
|
||||
});
|
||||
$detail.querySelector('#folderDelete').addEventListener('click', async () => {
|
||||
if (!confirm('Soft-delete this folder? Documents inside will move to Trash.')) return;
|
||||
try {
|
||||
await DMS.api('/folders.php?action=delete', { method: 'POST', body: { folder_id: folderId } });
|
||||
selectedFolderId = null;
|
||||
$detail.innerHTML = '<div class="dms-list__empty"><strong>Deleted</strong></div>';
|
||||
await loadTree();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
$detail.querySelector('#folderAddAcl').addEventListener('click', () => openAclModal(folderId));
|
||||
$detail.querySelectorAll('[data-pid]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Remove this access rule?')) return;
|
||||
try {
|
||||
await DMS.api('/folders.php?action=remove_permission', { method: 'POST', body: { permission_id: Number(btn.dataset.pid) } });
|
||||
showDetail(folderId);
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
$detail.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function openAclModal(folderId) {
|
||||
DMS.openModal({
|
||||
title: 'Add access rule',
|
||||
body:
|
||||
'<div class="dms-field"><label>Grantee type</label>' +
|
||||
'<select id="aclType"><option value="role">Role-based</option><option value="user">Specific user (by id)</option></select></div>' +
|
||||
'<div class="dms-field" id="aclRoleField"><label>Minimum role</label>' +
|
||||
'<select id="aclRole"><option>viewer</option><option>editor</option><option>admin</option><option>owner</option></select></div>' +
|
||||
'<div class="dms-field" id="aclUserField" style="display:none"><label>User ID</label><input type="number" id="aclUser"></div>' +
|
||||
'<div class="dms-field dms-field--inline"><label>Permissions</label>' +
|
||||
'<label><input type="checkbox" id="aclRead" checked> read</label>' +
|
||||
'<label><input type="checkbox" id="aclWrite"> write</label>' +
|
||||
'<label><input type="checkbox" id="aclManage"> manage</label></div>',
|
||||
actions: [
|
||||
{ label: 'Cancel', cls: 'dash-btn', onclick: DMS.closeModal },
|
||||
{ label: 'Add', cls: 'dash-btn dash-btn--primary', onclick: async () => {
|
||||
const type = document.getElementById('aclType').value;
|
||||
const body = { folder_id: folderId,
|
||||
can_read: document.getElementById('aclRead').checked,
|
||||
can_write: document.getElementById('aclWrite').checked,
|
||||
can_manage: document.getElementById('aclManage').checked,
|
||||
};
|
||||
if (type === 'role') body.min_role = document.getElementById('aclRole').value;
|
||||
else body.user_id = Number(document.getElementById('aclUser').value || 0);
|
||||
try {
|
||||
await DMS.api('/folders.php?action=set_permission', { method: 'POST', body });
|
||||
DMS.closeModal();
|
||||
showDetail(folderId);
|
||||
} catch (e) { alert(e.message); }
|
||||
}},
|
||||
],
|
||||
});
|
||||
document.getElementById('aclType').addEventListener('change', e => {
|
||||
document.getElementById('aclRoleField').style.display = e.target.value === 'role' ? '' : 'none';
|
||||
document.getElementById('aclUserField').style.display = e.target.value === 'user' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('dms:folder-changed', e => showDetail(e.detail.folderId));
|
||||
document.addEventListener('dms:reload-required', loadTree);
|
||||
document.addEventListener('dms:reload-tree', loadTree);
|
||||
|
||||
document.getElementById('folderNewBtn').addEventListener('click', () => DMS.folderModal.open({}));
|
||||
|
||||
loadTree();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
@@ -40,6 +40,13 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dms-kpis" id="dmsExtraKpis" aria-label="DMS overview">
|
||||
<div class="dms-kpi"><p class="dms-kpi__label">Storage used</p><p class="dms-kpi__value" id="dmsStorage">—</p><p class="dms-kpi__hint">across all documents</p></div>
|
||||
<div class="dms-kpi"><p class="dms-kpi__label">Folders</p><p class="dms-kpi__value" id="dmsFolders">—</p><p class="dms-kpi__hint">organising your library</p></div>
|
||||
<div class="dms-kpi"><p class="dms-kpi__label">In trash</p><p class="dms-kpi__value" id="dmsTrash">—</p><p class="dms-kpi__hint">auto-purges after 30d</p></div>
|
||||
<div class="dms-kpi"><p class="dms-kpi__label">Smart folders</p><p class="dms-kpi__value" id="dmsSmart">—</p><p class="dms-kpi__hint">saved views</p></div>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2 id="recentTitle"></h2>
|
||||
@@ -50,6 +57,11 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
<div id="dashRecent" class="dash-loading"></div>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head"><h2>Recent activity</h2></div>
|
||||
<div id="dmsActivity" class="dms-activity"><div class="dms-loading"></div></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -148,6 +160,54 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
recent.className = 'dash-error';
|
||||
recent.textContent = (I18N.error_loading || 'Could not load: ') + err.message;
|
||||
});
|
||||
|
||||
// DMS overview tiles
|
||||
(async function loadDmsKpis() {
|
||||
try {
|
||||
const tree = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
|
||||
const allDocs = await fetch(api + '/documents.php?action=list&limit=500', { credentials: 'same-origin' }).then(r => r.json());
|
||||
const storage = (allDocs.documents || []).reduce((s, d) => s + (d.file_size_bytes || 0), 0);
|
||||
document.getElementById('dmsStorage').textContent =
|
||||
storage < 1024*1024 ? Math.round(storage/1024) + ' KB'
|
||||
: storage < 1024*1024*1024 ? (storage/1024/1024).toFixed(1) + ' MB'
|
||||
: (storage/1024/1024/1024).toFixed(2) + ' GB';
|
||||
const countFolders = (nodes) => (nodes || []).reduce((n, x) => n + 1 + countFolders(x.children), 0);
|
||||
document.getElementById('dmsFolders').textContent = countFolders(tree.tree || []);
|
||||
document.getElementById('dmsTrash').textContent = tree.trash_count || 0;
|
||||
} catch (_) { /* ignored */ }
|
||||
try {
|
||||
const ss = await fetch(api + '/saved-searches.php?action=list', { credentials: 'same-origin' }).then(r => r.json());
|
||||
document.getElementById('dmsSmart').textContent = (ss.items || []).length;
|
||||
} catch (_) { /* ignored */ }
|
||||
})();
|
||||
|
||||
// Activity feed — gracefully degrades if endpoint absent
|
||||
(async function loadActivity() {
|
||||
const wrap = document.getElementById('dmsActivity');
|
||||
try {
|
||||
// We don't have a dedicated activity endpoint; fall back to /documents?action=list sorted by updated.
|
||||
const data = await fetch(api + '/documents.php?action=list&limit=15&sort=updated_at&dir=desc', { credentials: 'same-origin' }).then(r => r.json());
|
||||
const items = data.documents || [];
|
||||
if (!items.length) {
|
||||
wrap.innerHTML = '<div class="dms-list__empty"><strong>No activity yet</strong><span>Upload a document to get started.</span></div>';
|
||||
return;
|
||||
}
|
||||
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
||||
const rel = s => { if (!s) return '—'; const d = new Date(s.replace(' ', 'T') + 'Z'); const diff = (Date.now()-d)/1000;
|
||||
if (diff<60) return 'just now'; if (diff<3600) return Math.floor(diff/60)+'m'; if (diff<86400) return Math.floor(diff/3600)+'h';
|
||||
if (diff<86400*7) return Math.floor(diff/86400)+'d'; return d.toLocaleDateString(loc); };
|
||||
const iconForStatus = { ready:'✓', pending:'⏳', processing:'⚙', error:'⚠' };
|
||||
wrap.innerHTML = items.slice(0, 15).map(d =>
|
||||
'<div class="dms-activity__row">' +
|
||||
'<span class="dms-activity__icon">' + (iconForStatus[d.status] || '📄') + '</span>' +
|
||||
'<div><a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title) + '</a>' +
|
||||
(d.category ? ' <span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '') + '</div>' +
|
||||
'<span class="dms-activity__time">' + rel(d.updated_at || d.created_at) + '</span></div>'
|
||||
).join('');
|
||||
} catch (e) {
|
||||
wrap.innerHTML = '<div class="dms-list__empty"><strong>Could not load</strong><span>' + e.message + '</span></div>';
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
+32
-1
@@ -34,10 +34,20 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
<dt style="color:rgba(22,19,15,0.55);">Search method</dt>
|
||||
<dd>Hybrid (vector + keyword), reciprocal rank fusion, private boost 1.5×</dd>
|
||||
<dt style="color:rgba(22,19,15,0.55);">Graph DB</dt>
|
||||
<dd><code>bnl_legal</code> in FalkorDB (Colin) — citation edges</dd>
|
||||
<dd><code>dbn_client_graph</code> in FalkorDB (Colin) — citation edges</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2>Live diagnostics</h2>
|
||||
<div class="dash-card__actions">
|
||||
<button class="dash-btn" type="button" id="diagRefresh">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diagPanel" class="dms-diag"><div class="dms-loading"></div></div>
|
||||
</section>
|
||||
|
||||
<section class="dash-card">
|
||||
<div class="dash-card__head">
|
||||
<h2><?= htmlspecialchars(dbnToolsT('dash_section_privacy', $uiLang)) ?></h2>
|
||||
@@ -59,6 +69,27 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
document.getElementById('setClientId').textContent = d.clientId || '—';
|
||||
document.getElementById('setCorpusId').textContent = d.corpusId || '—';
|
||||
document.getElementById('setUserId').textContent = d.clientUserId || '—';
|
||||
|
||||
const $diag = document.getElementById('diagPanel');
|
||||
async function loadDiag() {
|
||||
$diag.innerHTML = '<div class="dms-loading"></div>';
|
||||
try {
|
||||
const r = await fetch((d.apiBase || '/api/dashboard') + '/diagnostics.php', { credentials:'same-origin' });
|
||||
const data = await r.json();
|
||||
if (!data.ok) throw new Error(data.message || 'Diagnostics unavailable');
|
||||
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
||||
$diag.innerHTML = data.sections.map(s =>
|
||||
'<div class="dms-diag__row" title="' + safe(s.detail || '') + '">' +
|
||||
'<div class="dms-diag__label">' + safe(s.label) + '</div>' +
|
||||
'<div class="dms-diag__value">' + safe(s.value) + '</div>' +
|
||||
'<div><span class="dms-diag__status dms-diag__status--' + safe(s.status || 'warn') + '">' + safe(s.status) + '</span></div></div>'
|
||||
).join('');
|
||||
} catch (e) {
|
||||
$diag.innerHTML = '<div class="dms-list__empty"><strong>Could not run diagnostics</strong><span>' + e.message + '</span></div>';
|
||||
}
|
||||
}
|
||||
document.getElementById('diagRefresh').addEventListener('click', loadDiag);
|
||||
loadDiag();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$dashboardPage = 'trash';
|
||||
$dashboardTitle = dbnToolsT('dash_title_trash', dbnToolsCurrentLanguage()) ?: 'Trash';
|
||||
$dashboardLead = dbnToolsT('dash_lead_trash', dbnToolsCurrentLanguage()) ?: 'Items here are auto-deleted after 30 days.';
|
||||
require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
?>
|
||||
<section class="dms-shell dms-shell--single">
|
||||
<div class="dms-main">
|
||||
<div class="dms-toolbar">
|
||||
<div class="dms-toolbar__crumbs"><span>🗑 Trash</span></div>
|
||||
<div class="dms-toolbar__actions">
|
||||
<button class="dash-btn" type="button" id="trashRestoreSel" disabled>Restore selected</button>
|
||||
<button class="dash-btn dash-btn--danger" type="button" id="trashPurgeAll">Empty trash (admin)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="trashList"><div class="dms-loading"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
const DMS = window.DBN_DMS;
|
||||
const $list = document.getElementById('trashList');
|
||||
const $restore = document.getElementById('trashRestoreSel');
|
||||
const $purge = document.getElementById('trashPurgeAll');
|
||||
const selected = new Set();
|
||||
|
||||
async function load() {
|
||||
$list.innerHTML = '<div class="dms-loading"></div>';
|
||||
try {
|
||||
const data = await DMS.api('/trash.php?action=list&limit=200');
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
$list.innerHTML = '<div class="dms-list"><div class="dms-list__empty"><strong>Trash is empty</strong><span>Nothing to restore.</span></div></div>';
|
||||
return;
|
||||
}
|
||||
const rows = items.map(it => {
|
||||
const id = (it.kind === 'document' ? 'd' : 'f') + it.id;
|
||||
const expires = (it.expires_in_days ?? 0) + 'd left';
|
||||
return '<div class="dms-list__row" data-key="' + id + '" data-kind="' + it.kind + '" data-id="' + it.id + '">' +
|
||||
'<input type="checkbox" class="dms-trash-sel">' +
|
||||
'<div class="dms-list__title">' +
|
||||
'<span class="dms-list__title-icon">' + (it.kind === 'folder' ? '📁' : DMS.fileIcon(it.source_type)) + '</span>' +
|
||||
DMS.safe(it.title || it.name || '(untitled)') +
|
||||
'</div>' +
|
||||
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.safe(it.kind) + '</div>' +
|
||||
'<div class="dms-list__cell dms-list__cell--muted">' + DMS.fmtRelative(it.deleted_at) + '</div>' +
|
||||
'<div class="dms-list__cell dms-list__cell--muted">' + expires + '</div>' +
|
||||
'<div class="dms-list__cell"></div>' +
|
||||
'<button class="dms-list__more" data-id="' + it.id + '" data-kind="' + it.kind + '" type="button">⋯</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
$list.innerHTML = '<div class="dms-list">' +
|
||||
'<div class="dms-list__head"><span></span><span>Title</span><span>Kind</span><span>Trashed</span><span>Expires</span><span></span><span></span></div>' +
|
||||
rows + '</div>';
|
||||
|
||||
$list.querySelectorAll('.dms-trash-sel').forEach(cb => {
|
||||
cb.addEventListener('change', e => {
|
||||
const row = e.target.closest('.dms-list__row');
|
||||
if (e.target.checked) selected.add(row.dataset.key); else selected.delete(row.dataset.key);
|
||||
$restore.disabled = selected.size === 0;
|
||||
});
|
||||
});
|
||||
$list.querySelectorAll('.dms-list__more').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const id = Number(btn.dataset.id);
|
||||
const kind = btn.dataset.kind;
|
||||
DMS.openCtxMenu(btn.getBoundingClientRect().left, btn.getBoundingClientRect().bottom, [
|
||||
{ label: 'Restore', icon: '↩', onclick: () => restoreItems([{kind, id}]) },
|
||||
'-',
|
||||
{ label: 'Delete permanently', icon: '🔥', danger: true,
|
||||
onclick: () => purgeItems([{kind, id}]) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
$list.innerHTML = '<div class="dms-list__empty"><strong>Error</strong><span>' + DMS.safe(e.message) + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreItems(items) {
|
||||
const body = { document_ids: [], folder_ids: [] };
|
||||
items.forEach(i => {
|
||||
if (i.kind === 'folder') body.folder_ids.push(i.id);
|
||||
else body.document_ids.push(i.id);
|
||||
});
|
||||
try {
|
||||
await DMS.api('/trash.php?action=restore', { method: 'POST', body });
|
||||
selected.clear();
|
||||
$restore.disabled = true;
|
||||
load();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
async function purgeItems(items) {
|
||||
if (!confirm('Permanently delete? This cannot be undone.')) return;
|
||||
const body = { document_ids: [], folder_ids: [] };
|
||||
items.forEach(i => {
|
||||
if (i.kind === 'folder') body.folder_ids.push(i.id);
|
||||
else body.document_ids.push(i.id);
|
||||
});
|
||||
try {
|
||||
await DMS.api('/trash.php?action=purge', { method: 'POST', body });
|
||||
load();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
$restore.addEventListener('click', () => {
|
||||
const items = Array.from(selected).map(k => ({ kind: k[0] === 'd' ? 'document' : 'folder', id: Number(k.slice(1)) }));
|
||||
restoreItems(items);
|
||||
});
|
||||
$purge.addEventListener('click', async () => {
|
||||
if (!confirm('Permanently delete ALL items in trash? This cannot be undone.')) return;
|
||||
try {
|
||||
await DMS.api('/trash.php?action=purge', { method: 'POST', body: { all: true } });
|
||||
load();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||
+52
-5
@@ -18,10 +18,19 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
|
||||
<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>
|
||||
<input type="file" name="file" id="upFile" accept=".pdf,.docx,.txt,.md,.html,.htm,.csv,.xlsx,.pptx,.json" hidden>
|
||||
<span class="upload-drop__icon">📥</span>
|
||||
<strong id="upDropHint"><?= htmlspecialchars(dbnToolsT('dash_upload_drop_strong', $uiLang)) ?></strong>
|
||||
<small><?= htmlspecialchars(dbnToolsT('dash_upload_drop_small', $uiLang)) ?></small>
|
||||
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;">
|
||||
<span class="dms-chip">PDF</span><span class="dms-chip">DOCX</span><span class="dms-chip">TXT</span>
|
||||
<span class="dms-chip">MD</span><span class="dms-chip">HTML</span><span class="dms-chip">CSV</span>
|
||||
<span class="dms-chip">XLSX</span><span class="dms-chip">PPTX</span><span class="dms-chip">JSON</span>
|
||||
</div>
|
||||
</label>
|
||||
<label style="display:grid;gap:4px;font-size:13px;">
|
||||
<span>📁 Destination folder</span>
|
||||
<select name="folder_id" id="upFolderSel"><option value="">(Unassigned)</option></select>
|
||||
</label>
|
||||
<div class="upload-meta">
|
||||
<label><?= htmlspecialchars(dbnToolsT('dash_upload_title_lbl', $uiLang)) ?><input name="title" placeholder="<?= htmlspecialchars(dbnToolsT('dash_upload_title_ph', $uiLang)) ?>"></label>
|
||||
@@ -139,13 +148,51 @@ require_once __DIR__ . '/../includes/layout_dashboard.php';
|
||||
|
||||
function safe(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); }
|
||||
|
||||
forms.file.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
// Populate folder picker from /api/dashboard/folders.php
|
||||
(async function loadFolders() {
|
||||
try {
|
||||
const data = await fetch(api + '/folders.php?action=list_tree', { credentials: 'same-origin' }).then(r => r.json());
|
||||
const sel = document.getElementById('upFolderSel');
|
||||
if (!sel || !data.tree) return;
|
||||
const flat = [];
|
||||
const walk = (nodes, prefix) => {
|
||||
nodes.forEach(n => {
|
||||
flat.push({ id: n.id, label: prefix + n.name });
|
||||
if (n.children && n.children.length) walk(n.children, prefix + n.name + ' / ');
|
||||
});
|
||||
};
|
||||
walk(data.tree || [], '');
|
||||
const opts = ['<option value="">(Unassigned)</option>'].concat(
|
||||
flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')
|
||||
);
|
||||
sel.innerHTML = opts.join('');
|
||||
// Preselect from ?folder=N
|
||||
const initial = new URLSearchParams(location.search).get('folder');
|
||||
if (initial) sel.value = initial;
|
||||
} catch (_) { /* ignored */ }
|
||||
})();
|
||||
|
||||
async function postFile(versionAction) {
|
||||
const fd = new FormData(forms.file);
|
||||
if (versionAction) fd.set('version_action', versionAction);
|
||||
const res = await fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
const json = await res.json();
|
||||
if (res.status === 409 && json && json.collision) {
|
||||
const action = await (window.DBN_DMS ? DBN_DMS.chooseCollisionAction(fileInput.files[0]?.name || '') : null);
|
||||
if (action) return postFile(action);
|
||||
setStatus('Cancelled.', 'err'); return null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
forms.file.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) { setStatus(I18N.upload_select_file || 'Select a file first.', 'err'); return; }
|
||||
setStatus(I18N.upload_indexing || 'Uploading and indexing…');
|
||||
fetch(api + '/upload.php', { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(r => r.json()).then(handleResult).catch(err => setStatus('❌ ' + safe(err.message), 'err'));
|
||||
try {
|
||||
const data = await postFile();
|
||||
if (data) handleResult(data);
|
||||
} catch (err) { setStatus('❌ ' + safe(err.message), 'err'); }
|
||||
});
|
||||
|
||||
forms.text.addEventListener('submit', (e) => {
|
||||
|
||||
Reference in New Issue
Block a user