Files
dobetternorge-tools/assets/js/dashboard/dms.js
T
daveadmin 2e2b0b45fa 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>
2026-05-26 22:24:56 +02:00

697 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* dms.js — Drive-style DMS interactivity bundle.
*
* Exposes window.DBN_DMS with helpers used by documents.php, folders.php,
* trash.php, document.php. Vanilla JS, no build step.
*/
(function () {
'use strict';
const API = (window.DBN_DASHBOARD && window.DBN_DASHBOARD.apiBase) || '/api/dashboard';
const I18N = window.DBN_I18N || {};
const LOC = I18N.locale || 'en-GB';
/* ─── utils ─── */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
function safe(s) {
return String(s == null ? '' : s)
.replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
}
function fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(0) + ' KB';
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(String(s).replace(' ', 'T') + 'Z')
.toLocaleDateString(LOC, { day:'numeric', month:'short', year:'numeric' }); }
catch (_) { return s; }
}
function fmtRelative(s) {
if (!s) return '—';
const d = new Date(String(s).replace(' ', 'T') + 'Z');
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return I18N.just_now || '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 fmtDate(s);
}
function fileIcon(srcType) {
const map = { pdf:'📄', docx:'📝', xlsx:'📊', pptx:'📑', csv:'📋',
html:'🌐', markdown:'📔', text:'📃', audio:'🔊', url:'🔗' };
return map[srcType] || '📄';
}
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ 'Accept': 'application/json' }, opts.headers || {});
let body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
headers['Content-Type'] = 'application/json';
}
const res = await fetch(API + path, {
method: opts.method || 'GET',
credentials: 'same-origin',
headers,
body,
});
let json = null;
try { json = await res.json(); } catch (_) { /* may be a stream */ }
if (!res.ok) {
const err = new Error((json && json.message) || ('HTTP ' + res.status));
err.status = res.status;
err.payload = json;
throw err;
}
return json;
}
/* ─── Folder tree ─── */
const Tree = {
state: { tree: [], activeFolderId: null, unassignedCount: 0, trashCount: 0, maxDepth: 2 },
async load() {
const data = await api('/folders.php?action=list_tree');
this.state.tree = data.tree || [];
this.state.unassignedCount = data.unassigned_count || 0;
this.state.trashCount = data.trash_count || 0;
this.state.maxDepth = data.max_depth || 2;
return this.state;
},
render(container, opts) {
opts = opts || {};
const active = String(opts.activeFolderId == null ? '' : opts.activeFolderId);
const html = [];
html.push('<div class="dms-tree__section">' + safe(I18N.dms_folders || 'Folders') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'all', name: I18N.dms_all_files || 'All files', icon: '🗂' },
active === 'all' || active === 'null', 0));
if (this.state.unassignedCount) {
html.push(this._row({ id: 'unassigned',
name: I18N.dms_unassigned || 'Unassigned',
icon: '📥',
doc_count: this.state.unassignedCount },
active === 'unassigned', 0));
}
(this.state.tree || []).forEach(node => {
html.push(this._renderNode(node, active, 0));
});
html.push('</ul>');
html.push('<div class="dms-tree__section">' + safe(I18N.dms_smart || 'Smart folders') + '</div>');
html.push('<ul class="dms-tree__list" id="dmsSmartList"><li class="dms-tree__node dms-tree__node--placeholder" style="opacity:.5;font-size:12px;padding-left:14px">—</li></ul>');
html.push('<button class="dms-tree__btn" type="button" data-action="new-folder">+ ' + safe(I18N.dms_new_folder || 'New folder') + '</button>');
html.push('<button class="dms-tree__btn" type="button" data-action="manage-folders">⚙ ' + safe(I18N.dms_manage_folders || 'Manage folders') + '</button>');
html.push('<div class="dms-tree__section" style="margin-top:8px">' + safe(I18N.dms_system || 'System') + '</div>');
html.push('<ul class="dms-tree__list">');
html.push(this._row({ id: 'trash', name: (I18N.dms_trash || 'Trash') + (this.state.trashCount ? '' : ''),
icon: '🗑', doc_count: this.state.trashCount }, active === 'trash', 0, { href: '/dashboard/trash.php' }));
html.push('</ul>');
container.innerHTML = html.join('');
container.addEventListener('click', this._onClick.bind(this));
this._installDropHandlers(container);
},
_row(node, isActive, depth, opts) {
opts = opts || {};
const count = node.doc_count ? ('<span class="dms-tree__count">' + node.doc_count + '</span>') : '';
const dot = node.color ? ('<span class="dms-tree__dot" style="background:' + safe(node.color) + '"></span>') : '';
const icon = node.icon || dot || '📁';
const tag = opts.href ? 'a' : 'div';
const href = opts.href ? (' href="' + safe(opts.href) + '"') : '';
return '<li><'+tag+' class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + safe(node.id) + '"' + href + '>'
+ '<span class="dms-tree__icon">' + (typeof icon === 'string' && icon.length < 3 ? icon : dot) + '</span>'
+ '<span class="dms-tree__label">' + safe(node.name) + '</span>'
+ count + '</'+tag+'></li>';
},
_renderNode(node, active, depth) {
const hasChildren = node.children && node.children.length;
const isActive = String(node.id) === active;
const html = [];
html.push('<li>');
html.push('<div class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"'
+ ' data-folder-id="' + node.id + '"'
+ ' data-droptarget="1">');
html.push('<span class="dms-tree__caret ' + (hasChildren ? 'is-open' : 'dms-tree__caret--empty') + '">▸</span>');
html.push('<span class="dms-tree__dot" style="background:' + (node.color || '#94a3b8') + '"></span>');
html.push('<span class="dms-tree__label">' + safe(node.name) + '</span>');
if (node.doc_count) html.push('<span class="dms-tree__count">' + node.doc_count + '</span>');
html.push('</div>');
if (hasChildren) {
html.push('<ul class="dms-tree__children">');
node.children.forEach(c => html.push(this._renderNode(c, active, depth + 1)));
html.push('</ul>');
}
html.push('</li>');
return html.join('');
},
_onClick(e) {
const node = e.target.closest('.dms-tree__node');
const btn = e.target.closest('[data-action]');
if (btn) {
e.preventDefault();
if (btn.dataset.action === 'new-folder') {
DMS.folderModal.open({ parentId: this.state.activeFolderId });
} else if (btn.dataset.action === 'manage-folders') {
window.location.href = '/dashboard/folders.php';
}
return;
}
if (node && !node.matches('a')) {
e.preventDefault();
const id = node.dataset.folderId;
this.state.activeFolderId = id;
document.dispatchEvent(new CustomEvent('dms:folder-changed', { detail: { folderId: id } }));
}
},
_installDropHandlers(container) {
container.addEventListener('dragover', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
$$('.is-drop-target', container).forEach(el => el.classList.remove('is-drop-target'));
node.classList.add('is-drop-target');
});
container.addEventListener('dragleave', e => {
const node = e.target.closest('[data-droptarget="1"]');
if (node) node.classList.remove('is-drop-target');
});
container.addEventListener('drop', async e => {
const node = e.target.closest('[data-droptarget="1"]');
if (!node) return;
e.preventDefault();
node.classList.remove('is-drop-target');
const folderId = node.dataset.folderId;
let payload;
try { payload = JSON.parse(e.dataTransfer.getData('application/x-dms')); } catch (_) { return; }
if (!payload || !payload.ids || !payload.ids.length) return;
try {
await api('/bulk.php', { method: 'POST', body: { op: 'move', ids: payload.ids, folder_id: folderId === 'unassigned' ? null : folderId } });
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert((I18N.dms_move_failed || 'Move failed') + ': ' + err.message);
}
});
},
};
/* ─── Folder create/rename modal ─── */
const folderModal = {
open(opts) {
opts = opts || {};
const isEdit = !!opts.folderId;
const m = openModal({
title: isEdit ? (I18N.dms_rename_folder || 'Rename folder') : (I18N.dms_new_folder || 'New folder'),
body:
'<div class="dms-field"><label>' + safe(I18N.dms_folder_name || 'Name') + '</label>' +
'<input id="dmsFolderName" type="text" maxlength="200" value="' + safe(opts.name || '') + '"></div>' +
'<div class="dms-field"><label>' + safe(I18N.dms_folder_color || 'Colour') + '</label>' +
'<input id="dmsFolderColor" type="color" value="' + safe(opts.color || '#3b82f6') + '"></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: closeModal },
{ label: isEdit ? (I18N.dms_save || 'Save') : (I18N.dms_create || 'Create'),
cls: 'dash-btn dash-btn--primary',
onclick: async () => {
const name = $('#dmsFolderName').value.trim();
const color = $('#dmsFolderColor').value;
if (!name) return;
try {
if (isEdit) {
await api('/folders.php?action=rename', { method: 'POST', body: { folder_id: opts.folderId, name } });
await api('/folders.php?action=recolor', { method: 'POST', body: { folder_id: opts.folderId, color } });
} else {
const res = await api('/folders.php?action=create', {
method: 'POST',
body: { name, color, parent_id: opts.parentId && opts.parentId !== 'all' && opts.parentId !== 'unassigned' ? opts.parentId : null },
});
if (opts.onCreated) opts.onCreated(res);
}
closeModal();
document.dispatchEvent(new CustomEvent('dms:reload-required'));
} catch (err) {
alert(err.message);
}
} },
],
});
setTimeout(() => $('#dmsFolderName').focus(), 30);
},
};
/* ─── Generic modal ─── */
let _modalRoot = null;
function openModal(opts) {
closeModal();
_modalRoot = document.createElement('div');
_modalRoot.className = 'dms-modal-backdrop';
const actionsHtml = (opts.actions || []).map((a, i) =>
'<button data-mi="' + i + '" class="' + safe(a.cls || 'dash-btn') + '">' + safe(a.label) + '</button>'
).join('');
_modalRoot.innerHTML =
'<div class="dms-modal ' + safe(opts.modalClass || '') + '">' +
'<div class="dms-modal__head">' +
'<h3 class="dms-modal__title">' + safe(opts.title || '') + '</h3>' +
'<button class="dms-modal__close" type="button" aria-label="Close">×</button>' +
'</div>' +
'<div class="dms-modal__body">' + (opts.body || '') + '</div>' +
(opts.actions ? '<div class="dms-modal__foot">' + actionsHtml + '</div>' : '') +
'</div>';
document.body.appendChild(_modalRoot);
_modalRoot.addEventListener('click', e => { if (e.target === _modalRoot) closeModal(); });
$('.dms-modal__close', _modalRoot).addEventListener('click', closeModal);
(opts.actions || []).forEach((a, i) => {
const b = _modalRoot.querySelector('[data-mi="' + i + '"]');
if (b && a.onclick) b.addEventListener('click', a.onclick);
});
return _modalRoot;
}
function closeModal() {
if (_modalRoot && _modalRoot.parentNode) _modalRoot.parentNode.removeChild(_modalRoot);
_modalRoot = null;
}
/* ─── Context menu ─── */
let _ctxMenu = null;
function openCtxMenu(x, y, items) {
closeCtxMenu();
_ctxMenu = document.createElement('div');
_ctxMenu.className = 'dms-ctx-menu';
_ctxMenu.style.left = x + 'px';
_ctxMenu.style.top = y + 'px';
_ctxMenu.innerHTML = items.map((it, i) => {
if (it === '-') return '<div class="dms-ctx-menu__sep"></div>';
return '<div class="dms-ctx-menu__item ' + (it.danger ? 'dms-ctx-menu__item--danger' : '') + '" data-i="' + i + '">'
+ (it.icon ? '<span>' + it.icon + '</span>' : '')
+ safe(it.label) + '</div>';
}).join('');
document.body.appendChild(_ctxMenu);
// Position correction if off-screen
const r = _ctxMenu.getBoundingClientRect();
if (r.right > window.innerWidth) _ctxMenu.style.left = (window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight) _ctxMenu.style.top = (window.innerHeight - r.height - 8) + 'px';
items.forEach((it, i) => {
if (it === '-') return;
const el = _ctxMenu.querySelector('[data-i="' + i + '"]');
if (el && it.onclick) el.addEventListener('click', () => { closeCtxMenu(); it.onclick(); });
});
setTimeout(() => document.addEventListener('click', closeCtxMenu, { once: true }), 0);
}
function closeCtxMenu() {
if (_ctxMenu && _ctxMenu.parentNode) _ctxMenu.parentNode.removeChild(_ctxMenu);
_ctxMenu = null;
}
/* ─── Doc list ─── */
const List = {
state: {
offset: 0, limit: 50, total: 0,
folderId: 'all', includeSubfolders: true,
q: '', status: '', category: '',
sort: 'updated_at', dir: 'desc',
selected: new Set(),
docs: [],
},
async load() {
const qs = new URLSearchParams({
action: 'list',
offset: String(this.state.offset),
limit: String(this.state.limit),
folder_id: String(this.state.folderId),
include_subfolders: this.state.includeSubfolders ? '1' : '0',
sort: this.state.sort,
dir: this.state.dir,
});
if (this.state.q) qs.set('q', this.state.q);
if (this.state.status) qs.set('status', this.state.status);
if (this.state.category) qs.set('category', this.state.category);
const data = await api('/documents.php?' + qs.toString());
this.state.docs = data.documents || [];
this.state.total = data.total || 0;
return data;
},
render(container) {
const docs = this.state.docs;
if (!docs.length) {
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__empty">' +
'<strong>' + safe(I18N.dms_empty_title || 'No documents here yet') + '</strong>' +
'<span>' + safe(I18N.dms_empty_hint || 'Drag files anywhere, or click Upload.') + '</span>' +
'</div></div>';
return;
}
const sortHead = (key, label) => {
const active = this.state.sort === key;
const arrow = active ? (this.state.dir === 'asc' ? ' ↑' : ' ↓') : '';
return '<button data-sort="' + key + '" class="' + (active ? 'is-sorted' : '') + '">' + safe(label) + arrow + '</button>';
};
const rows = docs.map(d => {
const isSel = this.state.selected.has(d.id);
const cat = d.category ? '<span class="dms-chip dms-chip--cat">' + safe(d.category) + '</span>' : '';
const tags = (d.tags || '').split(',').filter(Boolean).slice(0,3)
.map(t => '<span class="dms-chip dms-chip--tag">' + safe(t.trim()) + '</span>').join(' ');
return '<div class="dms-list__row" data-id="' + d.id + '" data-status="' + safe(d.status) + '"'
+ (isSel ? ' class="is-selected"' : '') + ' draggable="true">'
+ '<input type="checkbox" class="dms-sel"' + (isSel ? ' checked' : '') + '>'
+ '<div class="dms-list__title"><span class="dms-list__title-icon">' + fileIcon(d.source_type) + '</span>'
+ '<a href="/dashboard/document.php?id=' + d.id + '">' + safe(d.title || '(untitled)') + '</a></div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + cat + ' ' + tags + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + safe(d.author || '') + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + fmtRelative(d.updated_at || d.created_at) + '</div>'
+ '<div class="dms-list__cell dms-list__cell--muted">' + (d.file_size_bytes ? fmtBytes(d.file_size_bytes) : '—') + '</div>'
+ '<button class="dms-list__more" data-id="' + d.id + '" aria-label="Actions">⋯</button>'
+ '</div>';
}).join('');
container.innerHTML =
'<div class="dms-list">' +
'<div class="dms-list__head">' +
'<input type="checkbox" id="dmsSelAll">' +
sortHead('title', I18N.dms_col_title || 'Title') +
'<span>' + safe(I18N.dms_col_meta || 'Category · Tags') + '</span>' +
'<span>' + safe(I18N.dms_col_author || 'Author') + '</span>' +
sortHead('updated_at', I18N.dms_col_updated || 'Updated') +
sortHead('file_size_bytes', I18N.dms_col_size || 'Size') +
'<span></span>' +
'</div>' + rows +
'</div>' +
(this.state.selected.size ? this._bulkBar() : '');
this._wire(container);
},
_bulkBar() {
const n = this.state.selected.size;
return '<div class="dms-bulk-bar">' +
'<span class="dms-bulk-bar__count">' + n + ' ' + safe(I18N.dms_selected || 'selected') + '</span>' +
'<button class="dms-bulk-bar__btn" data-bulk="move">' + safe(I18N.dms_move || 'Move') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="retag">' + safe(I18N.dms_tag || 'Tag') + '</button>' +
'<button class="dms-bulk-bar__btn" data-bulk="recategorize">' + safe(I18N.dms_recategorize || 'Category') + '</button>' +
'<button class="dms-bulk-bar__btn dms-bulk-bar__btn--danger" data-bulk="trash">' + safe(I18N.dms_trash || 'Trash') + '</button>' +
'<button class="dms-bulk-bar__cancel" data-bulk="cancel">' + safe(I18N.dms_cancel || 'Cancel') + '</button>' +
'</div>';
},
_wire(container) {
// Selection
container.addEventListener('change', e => {
if (e.target.id === 'dmsSelAll') {
const checked = e.target.checked;
this.state.selected.clear();
if (checked) this.state.docs.forEach(d => this.state.selected.add(d.id));
this.render(container);
return;
}
if (e.target.classList.contains('dms-sel')) {
const row = e.target.closest('[data-id]');
const id = Number(row.dataset.id);
if (e.target.checked) this.state.selected.add(id); else this.state.selected.delete(id);
row.classList.toggle('is-selected', e.target.checked);
this.render(container);
}
});
// Sort
container.addEventListener('click', e => {
const sortBtn = e.target.closest('[data-sort]');
if (sortBtn) {
const key = sortBtn.dataset.sort;
if (this.state.sort === key) {
this.state.dir = this.state.dir === 'asc' ? 'desc' : 'asc';
} else {
this.state.sort = key; this.state.dir = 'desc';
}
this.load().then(() => this.render(container));
return;
}
const more = e.target.closest('.dms-list__more');
if (more) {
e.preventDefault();
e.stopPropagation();
const id = Number(more.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(more.getBoundingClientRect().left, more.getBoundingClientRect().bottom, doc, container);
return;
}
const bulk = e.target.closest('[data-bulk]');
if (bulk) {
this._bulkAction(bulk.dataset.bulk, container);
return;
}
});
// Right-click context menu
container.addEventListener('contextmenu', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
e.preventDefault();
const id = Number(row.dataset.id);
const doc = this.state.docs.find(d => d.id === id);
if (doc) this._openCtx(e.clientX, e.clientY, doc, container);
});
// Drag to folder tree
container.addEventListener('dragstart', e => {
const row = e.target.closest('.dms-list__row');
if (!row) return;
const id = Number(row.dataset.id);
const ids = this.state.selected.size && this.state.selected.has(id)
? Array.from(this.state.selected) : [id];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-dms', JSON.stringify({ ids }));
row.classList.add('is-dragging');
});
container.addEventListener('dragend', e => {
const row = e.target.closest('.dms-list__row');
if (row) row.classList.remove('is-dragging');
});
},
_openCtx(x, y, doc, container) {
openCtxMenu(x, y, [
{ label: I18N.dms_open || 'Open', icon: '📂', onclick: () => { window.location.href = '/dashboard/document.php?id=' + doc.id; } },
{ label: I18N.dms_download || 'Download', icon: '⬇',
onclick: () => { window.location.href = API + '/preview.php?id=' + doc.id + '&download=1'; } },
'-',
{ label: I18N.dms_move || 'Move…', icon: '➜', onclick: () => this._bulkAction('move', container, [doc.id]) },
{ label: I18N.dms_recategorize || 'Change category…', icon: '🏷',
onclick: () => this._bulkAction('recategorize', container, [doc.id]) },
{ label: I18N.dms_tag || 'Edit tags…', icon: '🔖', onclick: () => this._bulkAction('retag', container, [doc.id]) },
'-',
{ label: I18N.dms_trash || 'Move to trash', icon: '🗑', danger: true, onclick: () => this._bulkAction('trash', container, [doc.id]) },
]);
},
async _bulkAction(op, container, idsOverride) {
const ids = idsOverride || Array.from(this.state.selected);
if (!ids.length) return;
if (op === 'cancel') { this.state.selected.clear(); this.render(container); return; }
try {
if (op === 'move') {
const folderId = await pickFolder(I18N.dms_pick_destination || 'Pick destination folder');
if (folderId === undefined) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, folder_id: folderId } });
} else if (op === 'recategorize') {
const cat = prompt(I18N.dms_prompt_category || 'New category slug:');
if (!cat) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, category: cat } });
} else if (op === 'retag') {
const tags = prompt(I18N.dms_prompt_tags || 'Tags (comma-separated):');
if (tags === null) return;
await api('/bulk.php', { method: 'POST', body: { op, ids, tags, mode: 'replace' } });
} else {
if (op === 'trash' && !confirm((I18N.dms_confirm_trash || 'Move {n} document(s) to trash?').replace('{n}', ids.length))) return;
await api('/bulk.php', { method: 'POST', body: { op, ids } });
}
this.state.selected.clear();
await this.load();
this.render(container);
document.dispatchEvent(new CustomEvent('dms:reload-tree'));
} catch (err) {
alert(err.message);
}
},
};
/* ─── Folder picker prompt ─── */
async function pickFolder(title) {
try {
const data = await api('/folders.php?action=list_tree');
const flat = [];
const walk = (nodes, prefix) => {
(nodes || []).forEach(n => {
flat.push({ id: n.id, label: prefix + n.name });
if (n.children) walk(n.children, prefix + n.name + ' / ');
});
};
walk(data.tree || [], '');
return await new Promise(resolve => {
const opts = ['<option value="">' + safe(I18N.dms_unassigned || 'Unassigned') + '</option>']
.concat(flat.map(f => '<option value="' + f.id + '">' + safe(f.label) + '</option>')).join('');
openModal({
title,
body: '<div class="dms-field"><label>' + safe(I18N.dms_destination || 'Destination') + '</label>' +
'<select id="dmsPickFolder">' + opts + '</select></div>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(undefined); } },
{ label: I18N.dms_move || 'Move', cls: 'dash-btn dash-btn--primary',
onclick: () => {
const v = $('#dmsPickFolder').value;
closeModal();
resolve(v ? Number(v) : null);
} },
],
});
});
} catch (err) {
alert(err.message);
return undefined;
}
}
/* ─── Smart-folder sidebar (saved searches) ─── */
const Smart = {
async load() {
try {
const data = await api('/saved-searches.php?action=list');
const items = data.items || [];
const ul = $('#dmsSmartList');
if (!ul) return;
if (!items.length) {
ul.innerHTML = '<li class="dms-tree__node" style="opacity:.5;font-size:12px;padding-left:14px">' +
safe(I18N.dms_smart_empty || '(none yet)') + '</li>';
return;
}
ul.innerHTML = items.map(s =>
'<li><div class="dms-tree__node" data-smart="' + s.id + '">' +
'<span class="dms-tree__icon">' + (s.icon || '★') + '</span>' +
'<span class="dms-tree__label">' + safe(s.name) + '</span>' +
(s.is_shared ? '<span class="dms-tree__count">∞</span>' : '') +
'</div></li>'
).join('');
ul.addEventListener('click', e => {
const node = e.target.closest('[data-smart]');
if (!node) return;
const item = items.find(i => i.id === Number(node.dataset.smart));
if (!item) return;
document.dispatchEvent(new CustomEvent('dms:apply-smart', { detail: item.query || {} }));
});
} catch (_) { /* ignored */ }
},
async save(query) {
const name = prompt(I18N.dms_smart_name_prompt || 'Name for this smart folder:');
if (!name) return;
try {
await api('/saved-searches.php?action=create', { method: 'POST', body: { name, query } });
await this.load();
} catch (err) {
alert(err.message);
}
},
};
/* ─── Drag-anywhere upload overlay ─── */
function installDropAnywhereUpload(opts) {
opts = opts || {};
const overlay = document.createElement('div');
overlay.className = 'dms-drop-overlay';
overlay.innerHTML = '<span>📥</span><span>' + safe(I18N.dms_drop_here || 'Drop files to upload') + '</span>';
document.body.appendChild(overlay);
let depth = 0;
window.addEventListener('dragenter', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
depth++;
overlay.classList.add('is-visible');
}
});
window.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (depth === 0) overlay.classList.remove('is-visible');
});
window.addEventListener('dragover', e => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) e.preventDefault();
});
window.addEventListener('drop', async e => {
depth = 0;
overlay.classList.remove('is-visible');
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
await uploadOne(file, opts.getFolderId ? opts.getFolderId() : null);
}
document.dispatchEvent(new CustomEvent('dms:reload-required'));
});
}
async function uploadOne(file, folderId, versionAction) {
const fd = new FormData();
fd.append('file', file);
if (folderId && folderId !== 'all') fd.append('folder_id', String(folderId));
if (versionAction) fd.append('version_action', versionAction);
try {
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 chooseCollisionAction(file.name);
if (!action) return null;
return uploadOne(file, folderId, action);
}
if (!res.ok) throw new Error(json && (json.message || json.error) || 'Upload failed');
return json;
} catch (err) {
alert(file.name + ': ' + err.message);
return null;
}
}
function chooseCollisionAction(filename) {
return new Promise(resolve => {
openModal({
title: I18N.dms_collision_title || 'Document already exists',
body: '<p>' + safe(
(I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename)
) + '</p>',
actions: [
{ label: I18N.dms_cancel || 'Cancel', cls: 'dash-btn', onclick: () => { closeModal(); resolve(null); } },
{ label: I18N.dms_keep_both || 'Keep both', cls: 'dash-btn',
onclick: () => { closeModal(); resolve('force_separate'); } },
{ label: I18N.dms_save_version || 'Save as new version', cls: 'dash-btn dash-btn--primary',
onclick: () => { closeModal(); resolve('new'); } },
],
});
});
}
/* ─── Public surface ─── */
const DMS = {
api, safe, fmtDate, fmtRelative, fmtBytes, fileIcon,
Tree, List, Smart,
openModal, closeModal, openCtxMenu, closeCtxMenu,
folderModal,
pickFolder, installDropAnywhereUpload, uploadOne, chooseCollisionAction,
};
window.DBN_DMS = DMS;
})();