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:
+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',
|
||||
|
||||
Reference in New Issue
Block a user