401 lines
19 KiB
JavaScript
401 lines
19 KiB
JavaScript
/* ===========================================================
|
|
Trash Plugin - Verstak v2 Frontend Bundle
|
|
Contract: window.VerstakPluginRegister(id, { components })
|
|
=========================================================== */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var PLUGIN_ID = 'verstak.trash';
|
|
|
|
var STYLES = [
|
|
'.trash-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
|
|
'.trash-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
|
|
'.trash-title{font-size:.88rem;font-weight:650;color:var(--vt-color-text-primary,#f4f7fb)}',
|
|
'.trash-count{font-size:.74rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
|
'.trash-spacer{flex:1}',
|
|
'.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}',
|
|
'.trash-search{min-width:12rem;flex:1 1 14rem}',
|
|
'.trash-select{max-width:11rem}',
|
|
'.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}',
|
|
'.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
|
|
'.trash-btn:disabled{opacity:.48;cursor:default}',
|
|
'.trash-btn-danger{border-color:rgba(233,69,96,.52);color:#ffb4bf;background:var(--vt-color-danger-muted,rgba(233,69,96,.14))}',
|
|
'.trash-status{min-height:1.25rem;padding:.4rem .75rem;border-bottom:1px solid rgba(32,43,70,.7);font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);flex-shrink:0}',
|
|
'.trash-status.error{color:#ffc6ce;background:rgba(233,69,96,.08)}',
|
|
'.trash-list{flex:1;min-height:0;overflow:auto}',
|
|
'.trash-header,.trash-row{display:grid;grid-template-columns:minmax(12rem,1.4fr) minmax(7rem,.75fr) minmax(14rem,1.6fr) minmax(8rem,.8fr) minmax(7rem,.65fr) auto;gap:.65rem;align-items:center;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72)}',
|
|
'.trash-header{position:sticky;top:0;z-index:1;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.68rem;font-weight:650;letter-spacing:.02em;text-transform:uppercase}',
|
|
'.trash-row:hover{background:var(--vt-color-surface-hover,#1b2440)}',
|
|
'.trash-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.84rem;font-weight:600}',
|
|
'.trash-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.76rem}',
|
|
'.trash-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}',
|
|
'.trash-type{display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
|
|
'.trash-actions{display:flex;justify-content:flex-end;gap:.35rem}',
|
|
'.trash-empty{height:100%;min-height:12rem;display:flex;align-items:center;justify-content:center;padding:2rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.88rem;text-align:center}',
|
|
'.trash-confirm-backdrop{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(5,7,16,.7)}',
|
|
'.trash-confirm{width:min(31rem,100%);border:1px solid rgba(233,69,96,.5);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-modal,0 16px 40px rgba(0,0,0,.45));padding:1rem}',
|
|
'.trash-confirm h3{margin:0;font-size:1rem;font-weight:650}',
|
|
'.trash-confirm p{margin:.55rem 0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.82rem;line-height:1.45;overflow-wrap:anywhere}',
|
|
'.trash-confirm-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}',
|
|
'.trash-confirm-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}',
|
|
'@media(max-width:900px){.trash-header{display:none}.trash-row{grid-template-columns:minmax(0,1fr) auto;gap:.45rem .75rem;padding:.7rem .75rem}.trash-row>span:nth-child(2),.trash-row>span:nth-child(3),.trash-row>span:nth-child(4),.trash-row>span:nth-child(5){grid-column:1}.trash-actions{grid-column:2;grid-row:1 / span 5;align-self:center;flex-direction:column}.trash-search{order:5;flex-basis:100%}.trash-select{max-width:none;flex:1 1 8rem}}'
|
|
].join('\n');
|
|
|
|
function injectStyles() {
|
|
if (document.getElementById('trash-style-injected')) return;
|
|
var style = document.createElement('style');
|
|
style.id = 'trash-style-injected';
|
|
style.textContent = STYLES;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function el(tag, attrs, children) {
|
|
var node = document.createElement(tag);
|
|
attrs = attrs || {};
|
|
Object.keys(attrs).forEach(function (key) {
|
|
var value = attrs[key];
|
|
if (value == null) return;
|
|
if (key === 'className') node.className = value;
|
|
else if (key === 'textContent') node.textContent = value;
|
|
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
else if (key.slice(0, 2) === 'on') node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
else if (key === 'value') node.value = value;
|
|
else if (key === 'disabled') node.disabled = !!value;
|
|
else node.setAttribute(key, value);
|
|
});
|
|
(Array.isArray(children) ? children : [children]).forEach(function (child) {
|
|
if (child == null) return;
|
|
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
});
|
|
return node;
|
|
}
|
|
|
|
function text(value) {
|
|
return String(value == null ? '' : value);
|
|
}
|
|
|
|
function cleanPath(value) {
|
|
return text(value).split('/').filter(Boolean).join('/');
|
|
}
|
|
|
|
function nameFor(entry) {
|
|
var path = cleanPath(entry && entry.originalPath);
|
|
return text(entry && entry.basename).trim() || path.split('/').pop() || 'Untitled item';
|
|
}
|
|
|
|
function workspaceFor(entry) {
|
|
return cleanPath(entry && entry.originalPath).split('/')[0] || 'Vault root';
|
|
}
|
|
|
|
function directoryFor(entry) {
|
|
var path = cleanPath(entry && entry.originalPath);
|
|
var parts = path.split('/');
|
|
parts.pop();
|
|
return parts.join('/') || '/';
|
|
}
|
|
|
|
function typeLabel(entry) {
|
|
return entry && entry.originalType === 'folder' ? 'Folder' : 'File';
|
|
}
|
|
|
|
function formatSize(value) {
|
|
var size = Number(value);
|
|
if (!Number.isFinite(size) || size <= 0) return '';
|
|
var units = ['B', 'KB', 'MB', 'GB'];
|
|
var unit = 0;
|
|
while (size >= 1024 && unit < units.length - 1) {
|
|
size /= 1024;
|
|
unit += 1;
|
|
}
|
|
return (unit === 0 ? String(Math.round(size)) : size.toFixed(size >= 10 ? 0 : 1)) + ' ' + units[unit];
|
|
}
|
|
|
|
function formatDate(value) {
|
|
var date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return text(value);
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
|
}).format(date);
|
|
}
|
|
|
|
function errorText(error) {
|
|
return error && error.message ? error.message : text(error);
|
|
}
|
|
|
|
function isConflict(error) {
|
|
return /conflict:/i.test(errorText(error));
|
|
}
|
|
|
|
function sortEntries(entries, mode) {
|
|
return entries.slice().sort(function (left, right) {
|
|
if (mode === 'date-asc') return text(left.deletedAt).localeCompare(text(right.deletedAt)) || text(left.trashId).localeCompare(text(right.trashId));
|
|
if (mode === 'name-asc') return nameFor(left).localeCompare(nameFor(right), undefined, { sensitivity: 'base' });
|
|
if (mode === 'type-asc') return typeLabel(left).localeCompare(typeLabel(right)) || nameFor(left).localeCompare(nameFor(right));
|
|
return text(right.deletedAt).localeCompare(text(left.deletedAt)) || text(right.trashId).localeCompare(text(left.trashId));
|
|
});
|
|
}
|
|
|
|
var TrashView = {
|
|
mount: function (containerEl, props, api) {
|
|
injectStyles();
|
|
var state = {
|
|
entries: [],
|
|
workspace: '',
|
|
query: '',
|
|
type: '',
|
|
sort: 'date-desc',
|
|
loading: true,
|
|
busyId: '',
|
|
confirmingId: '',
|
|
status: '',
|
|
statusError: false,
|
|
disposed: false
|
|
};
|
|
|
|
function workspaceOptions() {
|
|
var values = {};
|
|
state.entries.forEach(function (entry) { values[workspaceFor(entry)] = true; });
|
|
return Object.keys(values).sort(function (left, right) { return left.localeCompare(right); });
|
|
}
|
|
|
|
function visibleEntries() {
|
|
var query = state.query.trim().toLowerCase();
|
|
return sortEntries(state.entries.filter(function (entry) {
|
|
if (state.workspace && workspaceFor(entry) !== state.workspace) return false;
|
|
if (state.type && text(entry.originalType) !== state.type) return false;
|
|
if (!query) return true;
|
|
return [nameFor(entry), entry.originalPath, directoryFor(entry), workspaceFor(entry)].join(' ').toLowerCase().indexOf(query) !== -1;
|
|
}), state.sort);
|
|
}
|
|
|
|
function statusText(entries) {
|
|
if (state.loading) return 'Loading deleted items...';
|
|
if (state.status) return state.status;
|
|
return entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items';
|
|
}
|
|
|
|
function renderConfirmation() {
|
|
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
|
|
if (!entry) return null;
|
|
return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [
|
|
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete permanently' }, [
|
|
el('h3', {}, ['Delete permanently?']),
|
|
el('p', {}, ['This cannot be undone.']),
|
|
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
|
|
el('div', { className: 'trash-confirm-actions' }, [
|
|
el('button', {
|
|
className: 'trash-btn',
|
|
type: 'button',
|
|
'data-trash-confirm-cancel': entry.trashId,
|
|
onClick: function () { state.confirmingId = ''; render(); }
|
|
}, ['Cancel']),
|
|
el('button', {
|
|
className: 'trash-btn trash-btn-danger',
|
|
type: 'button',
|
|
disabled: state.busyId === entry.trashId,
|
|
'data-trash-confirm-delete': entry.trashId,
|
|
onClick: function () { deletePermanently(entry); }
|
|
}, [state.busyId === entry.trashId ? 'Deleting...' : 'Delete permanently'])
|
|
])
|
|
])
|
|
]);
|
|
}
|
|
|
|
function render() {
|
|
var rows = visibleEntries();
|
|
containerEl.innerHTML = '';
|
|
containerEl.className = 'trash-root';
|
|
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
|
|
|
|
var workspaceSelect = el('select', {
|
|
className: 'trash-control trash-select',
|
|
value: state.workspace,
|
|
'data-trash-filter-workspace': '',
|
|
onChange: function (event) { state.workspace = event.target.value; render(); }
|
|
}, [el('option', { value: '' }, ['All workspaces'])]);
|
|
workspaceOptions().forEach(function (workspace) {
|
|
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
|
|
});
|
|
|
|
var typeSelect = el('select', {
|
|
className: 'trash-control trash-select',
|
|
value: state.type,
|
|
'data-trash-filter-type': '',
|
|
onChange: function (event) { state.type = event.target.value; render(); }
|
|
}, [
|
|
el('option', { value: '' }, ['All types']),
|
|
el('option', { value: 'file' }, ['Files']),
|
|
el('option', { value: 'folder' }, ['Folders'])
|
|
]);
|
|
|
|
var sortSelect = el('select', {
|
|
className: 'trash-control trash-select',
|
|
value: state.sort,
|
|
'data-trash-sort': '',
|
|
onChange: function (event) { state.sort = event.target.value; render(); }
|
|
}, [
|
|
el('option', { value: 'date-desc' }, ['Deleted: newest']),
|
|
el('option', { value: 'date-asc' }, ['Deleted: oldest']),
|
|
el('option', { value: 'name-asc' }, ['Name']),
|
|
el('option', { value: 'type-asc' }, ['Type'])
|
|
]);
|
|
|
|
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
|
|
el('span', { className: 'trash-title' }, ['Trash']),
|
|
el('span', { className: 'trash-count' }, [state.entries.length + ' total']),
|
|
el('span', { className: 'trash-spacer' }),
|
|
el('input', {
|
|
className: 'trash-control trash-search',
|
|
type: 'search',
|
|
value: state.query,
|
|
placeholder: 'Filter name or path',
|
|
'data-trash-filter-search': '',
|
|
onInput: function (event) { state.query = event.target.value; render(); }
|
|
}),
|
|
workspaceSelect,
|
|
typeSelect,
|
|
sortSelect,
|
|
el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, ['Refresh'])
|
|
]));
|
|
containerEl.appendChild(el('div', {
|
|
className: 'trash-status' + (state.statusError ? ' error' : ''),
|
|
'data-trash-status': ''
|
|
}, [statusText(rows)]));
|
|
|
|
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
|
|
list.appendChild(el('div', { className: 'trash-header' }, [
|
|
el('span', {}, ['Name']),
|
|
el('span', {}, ['Workspace']),
|
|
el('span', {}, ['Original path']),
|
|
el('span', {}, ['Deleted']),
|
|
el('span', {}, ['Type / size']),
|
|
el('span', {}, ['Actions'])
|
|
]));
|
|
if (state.loading) {
|
|
list.appendChild(el('div', { className: 'trash-empty' }, ['Loading deleted items...']));
|
|
} else if (!rows.length) {
|
|
list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? 'No deleted items match the current filters.' : 'Trash is empty.']));
|
|
} else {
|
|
rows.forEach(function (entry) {
|
|
var size = formatSize(entry.size);
|
|
list.appendChild(el('div', {
|
|
className: 'trash-row',
|
|
'data-trash-row': entry.trashId,
|
|
'data-trash-workspace': workspaceFor(entry)
|
|
}, [
|
|
el('span', { className: 'trash-name', title: nameFor(entry) }, [nameFor(entry)]),
|
|
el('span', { className: 'trash-meta', title: workspaceFor(entry) }, [workspaceFor(entry)]),
|
|
el('span', { className: 'trash-meta trash-path', title: entry.originalPath || '' }, [entry.originalPath || directoryFor(entry)]),
|
|
el('span', { className: 'trash-meta' }, [formatDate(entry.deletedAt)]),
|
|
el('span', { className: 'trash-type' }, [typeLabel(entry) + (size ? ' - ' + size : '')]),
|
|
el('div', { className: 'trash-actions' }, [
|
|
el('button', {
|
|
className: 'trash-btn',
|
|
type: 'button',
|
|
disabled: state.busyId === entry.trashId,
|
|
'data-trash-restore': entry.trashId,
|
|
onClick: function () { restoreEntry(entry); }
|
|
}, [state.busyId === entry.trashId ? 'Restoring...' : 'Restore']),
|
|
el('button', {
|
|
className: 'trash-btn trash-btn-danger',
|
|
type: 'button',
|
|
disabled: state.busyId === entry.trashId,
|
|
'data-trash-delete': entry.trashId,
|
|
onClick: function () { state.confirmingId = entry.trashId; render(); }
|
|
}, ['Delete permanently'])
|
|
])
|
|
]));
|
|
});
|
|
}
|
|
containerEl.appendChild(list);
|
|
var confirmation = renderConfirmation();
|
|
if (confirmation) containerEl.appendChild(confirmation);
|
|
}
|
|
|
|
function restoreEntry(entry) {
|
|
if (!entry || !entry.trashId || !api.files || typeof api.files.restoreTrash !== 'function') return;
|
|
state.busyId = entry.trashId;
|
|
state.status = '';
|
|
state.statusError = false;
|
|
render();
|
|
api.files.restoreTrash(entry.trashId, { overwrite: false }).then(function () {
|
|
if (state.disposed) return;
|
|
state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; });
|
|
state.busyId = '';
|
|
state.status = 'Restored ' + nameFor(entry) + '.';
|
|
render();
|
|
}).catch(function (error) {
|
|
if (state.disposed) return;
|
|
state.busyId = '';
|
|
state.statusError = true;
|
|
state.status = isConflict(error)
|
|
? 'Restore blocked: an item already exists at the original path. Nothing was overwritten.'
|
|
: 'Restore failed: ' + errorText(error);
|
|
render();
|
|
});
|
|
}
|
|
|
|
function deletePermanently(entry) {
|
|
if (!entry || !entry.trashId || !api.files || typeof api.files.deleteTrash !== 'function') return;
|
|
state.busyId = entry.trashId;
|
|
state.status = '';
|
|
state.statusError = false;
|
|
render();
|
|
api.files.deleteTrash(entry.trashId).then(function () {
|
|
if (state.disposed) return;
|
|
state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; });
|
|
state.busyId = '';
|
|
state.confirmingId = '';
|
|
state.status = 'Permanently deleted ' + nameFor(entry) + '.';
|
|
render();
|
|
}).catch(function (error) {
|
|
if (state.disposed) return;
|
|
state.busyId = '';
|
|
state.statusError = true;
|
|
state.status = 'Permanent delete failed: ' + errorText(error);
|
|
render();
|
|
});
|
|
}
|
|
|
|
function loadEntries() {
|
|
if (!api.files || typeof api.files.listTrash !== 'function') {
|
|
state.loading = false;
|
|
state.statusError = true;
|
|
state.status = 'Trash is unavailable for this plugin configuration.';
|
|
render();
|
|
return;
|
|
}
|
|
state.loading = true;
|
|
state.status = '';
|
|
state.statusError = false;
|
|
render();
|
|
api.files.listTrash().then(function (entries) {
|
|
if (state.disposed) return;
|
|
state.entries = Array.isArray(entries) ? entries : [];
|
|
state.loading = false;
|
|
render();
|
|
}).catch(function (error) {
|
|
if (state.disposed) return;
|
|
state.entries = [];
|
|
state.loading = false;
|
|
state.statusError = true;
|
|
state.status = 'Could not load Trash: ' + errorText(error);
|
|
render();
|
|
});
|
|
}
|
|
|
|
loadEntries();
|
|
containerEl.__trashCleanup = function () {
|
|
state.disposed = true;
|
|
containerEl.innerHTML = '';
|
|
};
|
|
},
|
|
unmount: function (containerEl) {
|
|
if (containerEl && typeof containerEl.__trashCleanup === 'function') containerEl.__trashCleanup();
|
|
}
|
|
};
|
|
|
|
window.VerstakPluginRegister(PLUGIN_ID, { components: { TrashView: TrashView } });
|
|
})();
|