/** * 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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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('
' + safe(I18N.dms_folders || 'Folders') + '
'); html.push(''); html.push('
' + safe(I18N.dms_smart || 'Smart folders') + '
'); html.push(''); html.push(''); html.push(''); html.push('
' + safe(I18N.dms_system || 'System') + '
'); html.push(''); 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 ? ('' + node.doc_count + '') : ''; const dot = node.color ? ('') : ''; const icon = node.icon || dot || '📁'; const tag = opts.href ? 'a' : 'div'; const href = opts.href ? (' href="' + safe(opts.href) + '"') : ''; return '
  • <'+tag+' class="dms-tree__node ' + (isActive ? 'is-active' : '') + '"' + ' data-folder-id="' + safe(node.id) + '"' + href + '>' + '' + (typeof icon === 'string' && icon.length < 3 ? icon : dot) + '' + '' + safe(node.name) + '' + count + '
  • '; }, _renderNode(node, active, depth) { const hasChildren = node.children && node.children.length; const isActive = String(node.id) === active; const html = []; html.push('
  • '); html.push('
    '); html.push(''); html.push(''); html.push('' + safe(node.name) + ''); if (node.doc_count) html.push('' + node.doc_count + ''); html.push('
    '); if (hasChildren) { html.push(''); } html.push('
  • '); 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: '
    ' + '
    ' + '
    ' + '
    ', 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) => '' ).join(''); _modalRoot.innerHTML = '
    ' + '
    ' + '

    ' + safe(opts.title || '') + '

    ' + '' + '
    ' + '
    ' + (opts.body || '') + '
    ' + (opts.actions ? '
    ' + actionsHtml + '
    ' : '') + '
    '; 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 '
    '; return '
    ' + (it.icon ? '' + it.icon + '' : '') + safe(it.label) + '
    '; }).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 = '
    ' + '
    ' + '' + safe(I18N.dms_empty_title || 'No documents here yet') + '' + '' + safe(I18N.dms_empty_hint || 'Drag files anywhere, or click Upload.') + '' + '
    '; return; } const sortHead = (key, label) => { const active = this.state.sort === key; const arrow = active ? (this.state.dir === 'asc' ? ' ↑' : ' ↓') : ''; return ''; }; const rows = docs.map(d => { const isSel = this.state.selected.has(d.id); const cat = d.category ? '' + safe(d.category) + '' : ''; const tags = (d.tags || '').split(',').filter(Boolean).slice(0,3) .map(t => '' + safe(t.trim()) + '').join(' '); return '
    ' + '' + '
    ' + fileIcon(d.source_type) + '' + '' + safe(d.title || '(untitled)') + '
    ' + '
    ' + cat + ' ' + tags + '
    ' + '
    ' + safe(d.author || '') + '
    ' + '
    ' + fmtRelative(d.updated_at || d.created_at) + '
    ' + '
    ' + (d.file_size_bytes ? fmtBytes(d.file_size_bytes) : '—') + '
    ' + '' + '
    '; }).join(''); container.innerHTML = '
    ' + '
    ' + '' + sortHead('title', I18N.dms_col_title || 'Title') + '' + safe(I18N.dms_col_meta || 'Category · Tags') + '' + '' + safe(I18N.dms_col_author || 'Author') + '' + sortHead('updated_at', I18N.dms_col_updated || 'Updated') + sortHead('file_size_bytes', I18N.dms_col_size || 'Size') + '' + '
    ' + rows + '
    ' + (this.state.selected.size ? this._bulkBar() : ''); this._wire(container); }, _bulkBar() { const n = this.state.selected.size; return '
    ' + '' + n + ' ' + safe(I18N.dms_selected || 'selected') + '' + '' + '' + '' + '' + '' + '
    '; }, _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 = [''] .concat(flat.map(f => '')).join(''); openModal({ title, body: '
    ' + '
    ', 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 = '
  • ' + safe(I18N.dms_smart_empty || '(none yet)') + '
  • '; return; } ul.innerHTML = items.map(s => '
  • ' + '' + (s.icon || '★') + '' + '' + safe(s.name) + '' + (s.is_shared ? '' : '') + '
  • ' ).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 = '📥' + safe(I18N.dms_drop_here || 'Drop files to upload') + ''; 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: '

    ' + safe( (I18N.dms_collision_body || 'A document with the same title already exists in this folder ({name}). What should we do?').replace('{name}', filename) ) + '

    ', 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; })();