/**
* ─────────────────────────────────────────────
* SPB Drive — Personal Cloud Drive
* ─────────────────────────────────────────────
* Author : Simon-Pierre Boucher
* Contact : contact@spboucher.ai
* File : src/web/assets/js/app.js
* Purpose : Main SPA — routing, browsing, selection, dnd, uploads, shares,
* search, trash, tags, settings, activity, keyboard shortcuts
* License : MIT © Simon-Pierre Boucher
* ─────────────────────────────────────────────
*/
import { getJSON, post, patch, del } from './api.js';
import {
h, fmtSize, fmtDate, toast, modal, confirmModal, contextMenu, copyText,
} from './ui.js';
import { UI, nodeIcon, folderIcon, fileIcon, EMPTY_ART } from './icons.js';
import { UploadManager, collectDropped, bindPasteUpload } from './upload.js';
import { Viewer } from './viewer.js';
import { Editor, isEditable } from './editor.js';
const FOLDER_COLORS = ['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'purple', 'pink'];
const state = {
rootId: 1,
route: { view: 'folder', id: 1 },
nodes: [], // nodes in current view
path: [],
selection: new Set(),
anchor: null, // shift-select anchor id
sort: 'name',
dir: 'asc',
viewMode: localStorage.getItem('spbdrive-view') ?? 'grid',
viewModes: JSON.parse(localStorage.getItem('spbdrive-view-per-folder') ?? '{}'),
cardSize: Number(localStorage.getItem('spbdrive-card') ?? 168),
tree: [],
tags: [],
infoNode: null,
clipboard: null, // {ids, cut}
};
const $ = (id) => document.getElementById(id);
const content = $('content');
const toolbar = $('toolbar');
const IS_TOUCH = window.matchMedia('(pointer: coarse)').matches;
// ── Boot ─────────────────────────────────────────────────────────────
// One conflict answer can apply to the whole upload batch.
let batchConflictChoice = null;
let conflictQueue = Promise.resolve();
function askUploadConflict(item, existing) {
const run = async () => {
if (batchConflictChoice) return batchConflictChoice;
return new Promise((resolve) => {
const applyAll = h('input', { type: 'checkbox' });
modal({
title: 'File already exists',
body: h('div', {},
h('p', { style: { color: 'var(--muted)', margin: '0 0 10px' } },
`"${existing.name}" (${fmtSize(existing.size)}) is already in this folder.`),
h('p', { style: { fontSize: '12.5px', margin: '0 0 10px' } },
'Replace keeps the old content in the file’s version history.'),
h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
applyAll, 'Apply to all remaining conflicts')),
onClose: () => resolve('skip'),
actions: [
{ label: 'Skip', onClick: () => { if (applyAll.checked) batchConflictChoice = 'skip'; resolve('skip'); } },
{ label: 'Replace', danger: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'replace'; resolve('replace'); } },
{ label: 'Keep both', primary: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'keep-both'; resolve('keep-both'); } },
],
});
});
};
conflictQueue = conflictQueue.then(run, run);
return conflictQueue;
}
const uploads = new UploadManager({
onFinished: () => { batchConflictChoice = null; refreshCurrent(); refreshSidebar(); },
resolveConflict: askUploadConflict,
});
async function boot() {
try {
const me = await getJSON('/api/v1/me');
state.rootId = me.rootId;
} catch { return; }
wireChrome();
bindPasteUpload(uploads, () => currentFolderId());
await refreshSidebar();
window.addEventListener('hashchange', onRoute);
onRoute();
}
function currentFolderId() {
return state.route.view === 'folder' ? state.route.id : state.rootId;
}
// ── Routing ──────────────────────────────────────────────────────────
function onRoute() {
const hash = location.hash.slice(2) || `folder/${state.rootId}`;
const [view, arg] = hash.split('/');
const q = new URLSearchParams(hash.split('?')[1] ?? '');
state.route = { view: view.split('?')[0], id: Number(arg) || arg, q };
state.selection.clear();
state.infoNode = null;
updateInfoPanel();
const views = {
folder: () => loadFolder(Number(state.route.id) || state.rootId),
recent: loadRecent,
starred: loadStarred,
shared: loadShares,
trash: loadTrash,
tag: () => loadTag(Number(state.route.id)),
search: () => loadSearch(decodeURIComponent(String(state.route.id ?? ''))),
activity: loadActivity,
requests: loadRequests,
storage: loadStorage,
settings: loadSettings,
};
(views[state.route.view] ?? views.folder)();
highlightSidebar();
toggleDrawer(false);
}
const go = (hash) => { location.hash = `#/${hash}`; };
// ── Chrome (topbar, sidebar skeleton) ────────────────────────────────
function wireChrome() {
$('sidebarToggle').innerHTML = UI.menu;
$('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
$('themeToggle').innerHTML = document.documentElement.dataset.theme === 'light' ? UI.moon : UI.sun;
$('settingsBtn').innerHTML = UI.gear;
$('sidebarToggle').onclick = () => toggleDrawer();
$('sidebarScrim').onclick = () => toggleDrawer(false);
$('uploadBtn').onclick = () => pickFiles();
$('viewToggle').onclick = toggleViewMode;
$('themeToggle').onclick = toggleTheme;
$('settingsBtn').onclick = () => go('settings');
$('newBtn').onclick = (e) => {
const r = e.currentTarget.getBoundingClientRect();
contextMenu(r.left, r.bottom + 4, newMenuItems());
};
// Bottom navigation (mobile)
const bn = $('bottomNav');
const bnIcons = { drive: UI.move, search: UI.search, new: UI.plus, shared: UI.link, menu: UI.menu };
bn.querySelectorAll('button').forEach((b) => { b.innerHTML = bnIcons[b.dataset.bn]; });
bn.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const k = btn.dataset.bn;
if (k === 'drive') go(`folder/${state.rootId}`);
else if (k === 'search') $('searchInput').focus();
else if (k === 'new') contextMenu(window.innerWidth / 2, window.innerHeight - 90, newMenuItems());
else if (k === 'shared') go('shared');
else if (k === 'menu') toggleDrawer();
});
$('filePick').onchange = (e) => {
uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.name })), currentFolderId());
e.target.value = '';
};
$('folderPick').onchange = (e) => {
uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.webkitRelativePath || f.name })), currentFolderId());
e.target.value = '';
};
// Global search box
const search = $('searchInput');
let debounce;
search.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
if (search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);
}, 350);
});
search.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);
if (e.key === 'Escape') search.blur();
});
// Full-window drag & drop
let dragDepth = 0;
window.addEventListener('dragenter', (e) => {
if (![...e.dataTransfer?.types ?? []].includes('Files')) return;
dragDepth += 1;
$('dropOverlay').classList.add('active');
});
window.addEventListener('dragleave', () => {
dragDepth = Math.max(0, dragDepth - 1);
if (!dragDepth) $('dropOverlay').classList.remove('active');
});
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', async (e) => {
e.preventDefault();
dragDepth = 0;
$('dropOverlay').classList.remove('active');
if (!e.dataTransfer?.files?.length && !e.dataTransfer?.items?.length) return;
const items = await collectDropped(e.dataTransfer);
if (items.length) uploads.add(items, currentFolderId());
});
document.addEventListener('keydown', onGlobalKey);
}
function pickFiles() { $('filePick').click(); }
function newMenuItems() {
return [
{ label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },
{ label: 'New text file', icon: UI.edit, onClick: newTextFileDialog },
{ sep: true },
{ label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },
{ label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },
{ sep: true },
{ label: 'Request files from someone…', icon: UI.inbox, onClick: () => newRequestDialog(currentFolderId()) },
];
}
/** Mobile sidebar drawer open/close (with scrim). */
function toggleDrawer(open) {
const willOpen = open ?? !$('sidebar').classList.contains('open');
$('sidebar').classList.toggle('open', willOpen);
$('sidebarScrim').classList.toggle('active', willOpen);
}
function toggleViewMode() {
state.viewMode = state.viewMode === 'grid' ? 'list' : 'grid';
localStorage.setItem('spbdrive-view', state.viewMode);
if (state.route.view === 'folder') {
state.viewModes[state.route.id] = state.viewMode;
localStorage.setItem('spbdrive-view-per-folder', JSON.stringify(state.viewModes));
}
$('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
renderNodes();
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('spbdrive-theme', next);
$('themeToggle').innerHTML = next === 'light' ? UI.moon : UI.sun;
document.getElementById('metaTheme')?.setAttribute('content', next === 'dark' ? '#131210' : '#f6f5f1');
}
// ── Sidebar ──────────────────────────────────────────────────────────
async function refreshSidebar() {
const [treeRes, tagsRes, stats] = await Promise.all([
getJSON('/api/v1/tree'), getJSON('/api/v1/tags'), getJSON('/api/v1/stats'),
]);
state.tree = treeRes.folders;
state.tags = tagsRes.tags;
const sections = $('navSections');
sections.innerHTML = '';
const navs = [
['My Drive', UI.move, `folder/${state.rootId}`],
['Recent', UI.clock, 'recent'],
['Starred', UI.starO, 'starred'],
['Shared', UI.link, 'shared'],
['File requests', UI.inbox, 'requests'],
['Storage', UI.storage, 'storage'],
['Activity', UI.activity, 'activity'],
['Trash', UI.trash, 'trash'],
];
for (const [label, icon, hash] of navs) {
sections.append(h('button.nav-item', { dataset: { nav: hash }, onclick: () => go(hash) },
h('span', { html: icon, style: { display: 'contents' } }), label));
}
renderTree();
renderTagList();
renderStorageMeter(stats);
highlightSidebar();
}
const expanded = new Set(JSON.parse(localStorage.getItem('spbdrive-expanded') ?? '[1]'));
function renderTree() {
const host = $('folderTree');
host.innerHTML = '';
const byParent = new Map();
for (const f of state.tree) {
if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
byParent.get(f.parent_id).push(f);
}
const build = (parentId, depth) => {
const frag = document.createDocumentFragment();
for (const folder of byParent.get(parentId) ?? []) {
const kids = byParent.get(folder.id) ?? [];
const row = h('div.tree-row', {
dataset: { folderId: folder.id, nav: `folder/${folder.id}` },
style: { paddingLeft: `${depth * 14}px` },
});
const toggle = h(`button.tree-toggle${expanded.has(folder.id) ? '.open' : ''}`, {
html: kids.length ? UI.chevron : '',
tabindex: kids.length ? 0 : -1,
onclick: (e) => {
e.stopPropagation();
expanded.has(folder.id) ? expanded.delete(folder.id) : expanded.add(folder.id);
localStorage.setItem('spbdrive-expanded', JSON.stringify([...expanded]));
renderTree();
},
});
const label = h('button.tree-label', {
onclick: () => go(`folder/${folder.id}`),
oncontextmenu: (e) => {
e.preventDefault();
nodeContextMenu(e, { id: folder.id, name: folder.name || 'My Drive', type: 'folder', color: folder.color, emoji: folder.emoji, starred: false, tags: [] });
},
},
h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
folder.id === state.rootId ? 'My Drive' : folder.name);
row.append(toggle, label);
makeFolderDropTarget(row, folder.id);
frag.append(row);
if (expanded.has(folder.id) && kids.length) frag.append(build(folder.id, depth + 1));
}
return frag;
};
host.append(build(null, 0));
}
function renderTagList() {
const host = $('tagList');
host.innerHTML = '';
for (const tag of state.tags) {
host.append(h('button.nav-item', { dataset: { nav: `tag/${tag.id}` }, onclick: () => go(`tag/${tag.id}`) },
h('span', { html: ``, style: { display: 'contents' } }),
tag.name,
h('span.count', {}, String(tag.count ?? ''))));
}
host.append(h('button.nav-item', {
onclick: () => editTagsDialog(),
style: { color: 'var(--muted)' },
}, h('span', { html: UI.plus, style: { display: 'contents' } }), 'Manage tags'));
}
// Storage bucket palette — validated (CVD + contrast) in both themes; the
// color follows the bucket, never its rank. Values live in tokens.css.
const BUCKET_COLORS = {
images: 'var(--cat-images)', video: 'var(--cat-video)', audio: 'var(--cat-audio)',
docs: 'var(--cat-docs)', archives: 'var(--cat-archives)', other: 'var(--cat-other)',
};
function renderStorageMeter(stats) {
const host = $('storageMeter');
const total = stats.usedBytes || 1;
host.innerHTML = '';
host.append(
h('div', { style: { fontSize: '12px', fontWeight: 600 } }, `${fmtSize(stats.usedBytes)} used`),
h('div.bar', {}, stats.byType.map((b) =>
h('i', { style: { width: `${(b.bytes / total) * 100}%`, background: BUCKET_COLORS[b.bucket] ?? '#8b93a3' }, title: `${b.bucket}: ${fmtSize(b.bytes)}` }))),
h('div.legend', {}, stats.byType.filter((b) => b.bytes > 0).map((b) =>
h('span', {}, h('span.dot', { style: { background: BUCKET_COLORS[b.bucket] } }), `${b.bucket} ${fmtSize(b.bytes)}`))),
h('div', { style: { fontSize: '11px', color: 'var(--muted)', marginTop: '5px' } }, `${stats.nodeCount} items · ${stats.blobCount} unique blobs`),
);
}
function highlightSidebar() {
const key = state.route.view === 'folder' ? `folder/${state.route.id}` : `${state.route.view}${state.route.id !== undefined && state.route.view === 'tag' ? `/${state.route.id}` : ''}`;
document.querySelectorAll('[data-nav]').forEach((el) => {
el.classList.toggle('active', el.dataset.nav === key);
});
document.querySelectorAll('#bottomNav [data-bn]').forEach((b) => {
b.classList.toggle('active',
(b.dataset.bn === 'drive' && state.route.view === 'folder') || b.dataset.bn === state.route.view);
});
}
// ── Views: folder / recent / starred / tag / search / trash ──────────
async function loadFolder(id) {
content.innerHTML = '
';
let data;
try {
data = await getJSON(`/api/v1/nodes/${id}/children?sort=${state.sort}&dir=${state.dir}`);
} catch {
go(`folder/${state.rootId}`);
return;
}
state.nodes = data.children;
state.path = data.path;
state.viewMode = state.viewModes[id] ?? state.viewMode;
$('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;
renderToolbar();
renderNodes();
}
async function loadRecent() {
const { items } = await getJSON('/api/v1/recent');
showFlatList('Recent', items, { empty: 'Nothing recent yet' });
}
async function loadStarred() {
const { items } = await getJSON('/api/v1/starred');
showFlatList('Starred', items, { empty: 'Star files and folders to find them here fast' });
}
async function loadTag(tagId) {
const { items } = await getJSON(`/api/v1/tags/${tagId}/nodes`);
const tag = state.tags.find((t) => t.id === tagId);
showFlatList(`Tag: ${tag?.name ?? ''}`, items, { empty: 'No items carry this tag' });
}
function showFlatList(title, items, { empty }) {
state.nodes = items;
state.path = [];
toolbar.innerHTML = '';
toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, title)), selectionToolbar());
content.innerHTML = '';
if (!items.length) {
content.append(emptyState('search', empty, ''));
return;
}
renderNodes();
}
async function loadSearch(q) {
$('searchInput').value = q;
const filters = state.route.q ?? new URLSearchParams();
const params = new URLSearchParams({ q });
for (const [k, v] of filters) params.set(k, v);
const { results } = await getJSON(`/api/v1/search?${params}`);
state.nodes = results;
state.path = [];
toolbar.innerHTML = '';
const chips = h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } });
const filterDefs = [
['type', ['image', 'video', 'audio', 'doc', 'archive', 'folder']],
];
for (const [key, values] of filterDefs) {
for (const value of values) {
const active = filters.get(key) === value;
chips.append(h(`button.chip${active ? '.active' : ''}`, {
onclick: () => {
const p = new URLSearchParams(filters);
active ? p.delete(key) : p.set(key, value);
location.hash = `#/search/${encodeURIComponent(q)}?${p}`;
},
}, value));
}
}
for (const flag of ['starred', 'shared']) {
const active = filters.get(flag) === 'true';
chips.append(h(`button.chip${active ? '.active' : ''}`, {
onclick: () => {
const p = new URLSearchParams(filters);
active ? p.delete(flag) : p.set(flag, 'true');
location.hash = `#/search/${encodeURIComponent(q)}?${p}`;
},
}, flag));
}
toolbar.append(
h('div.crumbs', {}, h('span.crumb.current', {}, `Search “${q}” — ${results.length} result${results.length === 1 ? '' : 's'}`)),
selectionToolbar(),
);
content.innerHTML = '';
content.append(chips, h('div', { style: { height: '12px' } }));
if (!results.length) {
content.append(emptyState('search', 'No results', 'Try other words, or check filters'));
return;
}
renderNodes(true);
}
async function loadTrash() {
const { items } = await getJSON('/api/v1/trash');
state.nodes = items;
state.path = [];
toolbar.innerHTML = '';
toolbar.append(
h('div.crumbs', {}, h('span.crumb.current', {}, 'Trash'), h('span.muted', { style: { fontSize: '12px', marginLeft: '10px' } }, 'Items are deleted forever after 30 days')),
items.length ? h('button.btn.danger', {
onclick: async () => {
if (await confirmModal({ title: 'Empty trash', message: `Permanently delete ${items.length} item(s)? This cannot be undone.`, confirmLabel: 'Delete forever', danger: true, typed: 'DELETE' })) {
await post('/api/v1/trash/empty', {});
toast('Trash emptied');
loadTrash();
refreshSidebar();
}
},
}, 'Empty trash') : null,
);
content.innerHTML = '';
if (!items.length) {
content.append(emptyState('trash', 'Trash is empty', 'Deleted items land here for 30 days'));
return;
}
renderNodes();
}
// ── Toolbar (breadcrumbs + sort + selection ops) ─────────────────────
function renderToolbar() {
toolbar.innerHTML = '';
const crumbs = h('div.crumbs');
state.path.forEach((part, i) => {
if (i > 0) crumbs.append(h('span.crumb-sep', {}, '›'));
const isLast = i === state.path.length - 1;
const crumb = h(`button.crumb${isLast ? '.current' : ''}`, {
onclick: () => go(`folder/${part.id}`),
}, part.name || 'My Drive');
makeFolderDropTarget(crumb, part.id);
crumbs.append(crumb);
});
const sortSel = h('select', {
style: { width: 'auto' },
onchange: (e) => {
const [sort, dir] = e.target.value.split(':');
state.sort = sort; state.dir = dir;
loadFolder(currentFolderId());
},
}, ...[['name:asc', 'Name ↑'], ['name:desc', 'Name ↓'], ['modified:desc', 'Newest'], ['modified:asc', 'Oldest'], ['size:desc', 'Largest'], ['size:asc', 'Smallest'], ['type:asc', 'Type']]
.map(([v, l]) => h('option', { value: v, selected: `${state.sort}:${state.dir}` === v }, l)));
const slider = state.viewMode === 'grid'
? h('input', {
type: 'range', min: 120, max: 260, step: 35, value: state.cardSize,
title: 'Thumbnail size', style: { width: '90px' },
oninput: (e) => {
state.cardSize = Number(e.target.value);
localStorage.setItem('spbdrive-card', e.target.value);
content.querySelector('.grid')?.style.setProperty('--card', `${state.cardSize}px`);
},
})
: null;
toolbar.append(crumbs, selectionToolbar(), sortSel, slider ?? '');
}
function selectionToolbar() {
const host = h('div.sel-toolbar');
updateSelectionToolbar(host);
return host;
}
function updateSelectionToolbar(host) {
host = host ?? toolbar.querySelector('.sel-toolbar');
if (!host) return;
host.innerHTML = '';
const n = state.selection.size;
if (!n) return;
const inTrash = state.route.view === 'trash';
host.append(h('span.sel-count', {}, `${n} selected`));
const ids = [...state.selection];
if (inTrash) {
host.append(
h('button.btn', { onclick: () => bulkRestore(ids) }, 'Restore'),
h('button.btn.danger', { onclick: () => bulkDeleteForever(ids) }, 'Delete forever'),
);
} else {
host.append(
h('button.btn.icon', { title: 'Download', html: UI.download, onclick: () => downloadIds(ids) }),
h('button.btn.icon', { title: 'Move', html: UI.move, onclick: () => moveDialog(ids) }),
h('button.btn.icon', { title: 'Trash (Del)', html: UI.trash, onclick: () => bulkTrash(ids) }),
);
}
}
// ── Node rendering (grid + list) ─────────────────────────────────────
function nodeById(id) { return state.nodes.find((n) => n.id === id); }
function renderNodes(withSnippets = false) {
const existingChips = state.route.view === 'search' ? [...content.children].slice(0, 2) : [];
content.innerHTML = '';
existingChips.forEach((c) => content.append(c));
if (!state.nodes.length && state.route.view === 'folder') {
content.append(emptyState('folder', 'This folder is empty', 'Drop files anywhere, or press Upload'));
return;
}
const host = state.viewMode === 'grid' ? renderGrid() : renderList(withSnippets);
content.append(host);
wireRectangleSelect(host);
updateSelectionToolbar();
}
function thumbEl(node) {
if (node.hasThumb) {
const img = h('img', { loading: 'lazy', src: `/thumb/${node.id}?size=256`, alt: '' });
img.onerror = () => img.replaceWith(h('span', { html: nodeIcon(node), style: { display: 'contents' } }));
return img;
}
return h('span', { html: nodeIcon(node), style: { display: 'contents' } });
}
function renderGrid() {
const grid = h('div.grid', { style: { '--card': `${state.cardSize}px` } });
for (const node of state.nodes) {
const card = h('div.card', { dataset: { id: node.id }, tabindex: 0 },
h('div.thumb', {}, thumbEl(node)),
h('div.meta', {},
h('span', { html: nodeIcon(node), style: { display: 'contents' } }),
h('span.name', { title: node.name }, node.name),
),
);
if (node.starred) card.append(h('span.star-ind', { html: UI.star }));
if (node.tags?.length) card.append(h('span.tag-strip', { style: { background: node.tags[0].color } }));
wireNode(card, node);
grid.append(card);
}
return grid;
}
function renderList(withSnippets) {
const list = h('div.list');
const headBtn = (label, key) => h('button', {
onclick: () => {
state.dir = state.sort === key && state.dir === 'asc' ? 'desc' : 'asc';
state.sort = key;
state.route.view === 'folder' ? loadFolder(currentFolderId()) : sortLocal();
},
}, label, state.sort === key ? (state.dir === 'asc' ? ' ↑' : ' ↓') : '');
list.append(h('div.list-header', {},
h('span'), headBtn('Name', 'name'), headBtn('Size', 'size'), headBtn('Type', 'type'), headBtn('Modified', 'modified'), h('span', {}, 'Tags')));
for (const node of state.nodes) {
const row = h('div.row', { dataset: { id: node.id }, tabindex: 0 },
h('span.star-cell', { html: node.starred ? UI.star : '' }),
h('div.name', {},
h('span', { html: nodeIcon(node), style: { display: 'contents' } }),
h('span', { title: node.name }, node.name),
),
h('span.cell', {}, node.type === 'folder' ? '—' : fmtSize(node.size)),
h('span.cell', {}, node.type === 'folder' ? 'Folder' : (node.mime?.split('/')[1] ?? node.strategy ?? 'file')),
h('span.cell', {}, fmtDate(node.modified)),
h('span.rowtags', {}, (node.tags ?? []).slice(0, 3).map((t) =>
h('span.chip', { style: { borderColor: t.color, color: t.color } }, t.name))),
);
if (withSnippets && node.snippet) {
row.append(h('div', { style: { gridColumn: '2 / -1', fontSize: '12px', color: 'var(--muted)' }, html: node.snippet }));
}
wireNode(row, node);
list.append(row);
}
return list;
}
function sortLocal() {
const dir = state.dir === 'asc' ? 1 : -1;
const key = state.sort;
state.nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
if (key === 'size' || key === 'modified') return (a[key] - b[key]) * dir;
return String(a[key === 'type' ? 'mime' : key] ?? '').localeCompare(String(b[key === 'type' ? 'mime' : key] ?? '')) * dir;
});
renderNodes();
}
function emptyState(art, title, subtitle) {
return h('div.empty', {}, h('div', {},
h('div', { html: EMPTY_ART[art] ?? EMPTY_ART.folder }),
h('h3', {}, title),
h('p', {}, subtitle)));
}
// ── Node interactions: select, open, dnd, context menu ───────────────
function wireNode(el, node) {
el.addEventListener('click', (e) => {
e.stopPropagation();
// Touch UX: a tap opens directly (long-press = menu / select).
if (IS_TOUCH && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
state.selection = new Set([node.id]);
state.anchor = node.id;
paintSelection();
openNode(node);
return;
}
if (e.shiftKey && state.anchor !== null) {
const ids = state.nodes.map((n) => n.id);
const a = ids.indexOf(state.anchor);
const b = ids.indexOf(node.id);
state.selection = new Set(ids.slice(Math.min(a, b), Math.max(a, b) + 1));
} else if (e.metaKey || e.ctrlKey) {
state.selection.has(node.id) ? state.selection.delete(node.id) : state.selection.add(node.id);
state.anchor = node.id;
} else {
state.selection = new Set([node.id]);
state.anchor = node.id;
}
paintSelection();
showInfo(node);
});
el.addEventListener('dblclick', () => openNode(node));
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') openNode(node);
});
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (!state.selection.has(node.id)) {
state.selection = new Set([node.id]);
state.anchor = node.id;
paintSelection();
}
nodeContextMenu(e, node);
});
// Touch: long-press opens the context menu (mobile has no right-click).
let pressTimer = null;
let pressFired = false;
el.addEventListener('touchstart', (e) => {
pressFired = false;
const t = e.touches[0];
pressTimer = setTimeout(() => {
pressFired = true;
navigator.vibrate?.(12);
state.selection = new Set([node.id]);
state.anchor = node.id;
paintSelection();
nodeContextMenu({ preventDefault() {}, clientX: t.clientX, clientY: t.clientY }, node);
}, 460);
}, { passive: true });
for (const ev of ['touchend', 'touchmove', 'touchcancel']) {
el.addEventListener(ev, (e) => {
clearTimeout(pressTimer);
// Swallow the tap that follows a long-press so it doesn't open the node.
if (pressFired && ev === 'touchend') e.preventDefault();
}, { passive: false });
}
// Drag to move
el.draggable = state.route.view !== 'trash';
el.addEventListener('dragstart', (e) => {
if (!state.selection.has(node.id)) {
state.selection = new Set([node.id]);
paintSelection();
}
e.dataTransfer.setData('application/x-spbdrive-ids', JSON.stringify([...state.selection]));
e.dataTransfer.effectAllowed = 'move';
});
if (node.type === 'folder') makeFolderDropTarget(el, node.id);
}
function makeFolderDropTarget(el, folderId) {
el.addEventListener('dragover', (e) => {
if (![...e.dataTransfer.types].includes('application/x-spbdrive-ids')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
el.classList.add('drop-target');
});
el.addEventListener('dragleave', () => el.classList.remove('drop-target'));
el.addEventListener('drop', async (e) => {
el.classList.remove('drop-target');
const raw = e.dataTransfer.getData('application/x-spbdrive-ids');
if (!raw) return;
e.preventDefault();
e.stopPropagation();
const ids = JSON.parse(raw).filter((id) => id !== folderId);
if (ids.length) await moveIds(ids, folderId);
});
}
function paintSelection() {
content.querySelectorAll('[data-id]').forEach((el) => {
el.classList.toggle('selected', state.selection.has(Number(el.dataset.id)));
});
updateSelectionToolbar();
}
function openNode(node) {
if (node.type === 'folder') {
if (state.route.view === 'trash') return;
go(`folder/${node.id}`);
return;
}
openViewer(node);
}
function openViewer(node) {
const files = state.nodes.filter((n) => n.type === 'file');
const index = files.findIndex((n) => n.id === node.id);
const viewer = new Viewer(files, Math.max(index, 0), {
descUrl: (n) => `/api/v1/preview/${n.id}`,
streamUrl: (n) => `/stream/${n.id}`,
dlUrl: (n) => `/dl/${n.id}`,
actions: [
{ title: 'Download', icon: UI.download, onClick: (n) => { location.href = `/dl/${n.id}`; } },
{
title: 'Edit', icon: UI.edit, visible: (n) => isEditable(n),
onClick: (n, v) => { v.close(); openEditor(n); },
},
{ title: 'Share', icon: UI.share, onClick: (n) => shareDialog(n) },
{
title: 'Star', icon: UI.starO,
onClick: async (n) => { await toggleStar(n); },
},
{ title: 'Info', icon: UI.info, onClick: (n, v) => { v.close(); showInfo(n, true); } },
{
title: 'Delete', icon: UI.trash,
onClick: async (n, v) => { v.close(); await bulkTrash([n.id]); },
},
],
});
return viewer;
}
function openEditor(node) {
return new Editor(node, {
onSaved: () => { refreshCurrent(); },
});
}
// ── Context menu ─────────────────────────────────────────────────────
function nodeContextMenu(e, node) {
const multi = state.selection.size > 1;
const ids = multi ? [...state.selection] : [node.id];
const inTrash = state.route.view === 'trash';
if (inTrash) {
contextMenu(e.clientX, e.clientY, [
{ label: multi ? `Restore ${ids.length} items` : 'Restore', icon: UI.restore, onClick: () => bulkRestore(ids) },
{ sep: true },
{ label: 'Delete forever', icon: UI.trash, danger: true, onClick: () => bulkDeleteForever(ids) },
]);
return;
}
const colorRow = node.type === 'folder' && !multi
? h('div.ctx-colors', {}, FOLDER_COLORS.map((c) =>
h('button', {
style: { background: `var(--folder-${c})` }, title: c,
onclick: async () => {
const { closeContextMenu } = await import('./ui.js');
closeContextMenu();
await patch(`/api/v1/nodes/${node.id}`, { color: c });
refreshCurrent(); refreshSidebar();
},
})))
: null;
contextMenu(e.clientX, e.clientY, [
!multi && { label: node.type === 'folder' ? 'Open' : 'Preview', icon: UI.eye, kbd: '↵', onClick: () => openNode(node) },
!multi && isEditable(node) && { label: 'Edit', icon: UI.edit, kbd: 'E', onClick: () => openEditor(node) },
!multi && node.type === 'file' && { label: 'Download', icon: UI.download, onClick: () => { location.href = `/dl/${node.id}`; } },
multi && { label: `Download ${ids.length} as ZIP`, icon: UI.download, onClick: () => downloadIds(ids) },
{ label: 'Share', icon: UI.share, onClick: () => shareDialog(node) },
{ sep: true },
!multi && { label: 'Rename', icon: UI.rename, kbd: 'F2', onClick: () => inlineRename(node) },
{ label: 'Move to…', icon: UI.move, onClick: () => moveDialog(ids) },
!multi && { label: 'Duplicate', icon: UI.duplicate, onClick: () => duplicateNode(node) },
!multi && node.type === 'file' && { label: 'Version history', icon: UI.history, onClick: () => versionsDialog(node) },
!multi && node.type === 'folder' && { label: 'Request files here…', icon: UI.inbox, onClick: () => newRequestDialog(node.id) },
{ label: node.starred && !multi ? 'Unstar' : 'Star', icon: UI.starO, kbd: 'S', onClick: () => Promise.all(ids.map((id) => toggleStar(nodeById(id) ?? node))).then(refreshCurrent) },
{ label: 'Tags…', icon: UI.tag, onClick: () => tagDialog(node) },
!multi && { label: 'Details', icon: UI.info, onClick: () => showInfo(node, true) },
colorRow && { sep: true },
colorRow && { custom: colorRow },
node.type === 'folder' && !multi && { custom: emojiRow(node) },
{ sep: true },
{ label: multi ? `Move ${ids.length} to trash` : 'Move to trash', icon: UI.trash, danger: true, kbd: 'Del', onClick: () => bulkTrash(ids) },
].filter(Boolean));
}
function emojiRow(node) {
const emojis = ['📁', '📸', '🎬', '🎵', '💼', '🧠', '🚀', '❤️', ''];
return h('div.ctx-colors', {}, emojis.map((em) =>
h('button', {
style: { background: 'var(--surface-2)', fontSize: '12px' }, title: em || 'none',
onclick: async () => {
const { closeContextMenu } = await import('./ui.js');
closeContextMenu();
await patch(`/api/v1/nodes/${node.id}`, { emoji: em || null });
refreshCurrent(); refreshSidebar();
},
}, em || '∅')));
}
// ── Operations ───────────────────────────────────────────────────────
function refreshCurrent() { onRoute(); }
async function newFolderDialog() {
const input = h('input', { type: 'text', placeholder: 'Folder name' });
modal({
title: 'New folder',
body: h('div', {}, input),
actions: [
{ label: 'Cancel', onClick: () => {} },
{
label: 'Create', primary: true,
onClick: async () => {
const name = input.value.trim();
if (!name) return false;
await post('/api/v1/nodes', { parentId: currentFolderId(), name, type: 'folder' });
refreshCurrent(); refreshSidebar();
return true;
},
},
],
});
}
async function newTextFileDialog() {
const stamp = new Date().toISOString().slice(0, 10);
const input = h('input', { type: 'text', value: `notes-${stamp}.md` });
modal({
title: 'New text file',
body: h('div', {}, input),
actions: [
{ label: 'Cancel', onClick: () => {} },
{
label: 'Create & edit', primary: true,
onClick: async () => {
const name = input.value.trim();
if (!name) return false;
const { node } = await post('/api/v1/files', { parentId: currentFolderId(), name, content: '' });
refreshCurrent();
openEditor(node);
return true;
},
},
],
});
const dot = input.value.lastIndexOf('.');
input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
}
async function toggleStar(node) {
const next = !node.starred;
node.starred = next; // optimistic
paintSelection();
try {
await patch(`/api/v1/nodes/${node.id}`, { starred: next });
} catch {
node.starred = !next;
toast('Could not update star', { error: true });
}
refreshCurrent();
}
function inlineRename(node) {
const el = content.querySelector(`[data-id="${node.id}"]`);
const nameEl = el?.querySelector('.name span:last-child, .meta .name');
if (!nameEl) return;
const input = h('input.rename-input', { type: 'text', value: node.name });
nameEl.replaceWith(input);
input.focus();
const dot = node.name.lastIndexOf('.');
input.setSelectionRange(0, node.type === 'file' && dot > 0 ? dot : node.name.length);
const done = async (commit) => {
input.onblur = null;
const name = input.value.trim();
if (commit && name && name !== node.name) {
try {
await patch(`/api/v1/nodes/${node.id}`, { name });
node.name = name;
} catch (err) {
toast(err.message, { error: true });
}
}
refreshCurrent();
if (node.type === 'folder') refreshSidebar();
};
input.onblur = () => done(true);
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === 'Enter') done(true);
if (e.key === 'Escape') done(false);
};
input.onclick = (e) => e.stopPropagation();
}
async function duplicateNode(node) {
await post(`/api/v1/nodes/${node.id}/duplicate`, {});
toast('Duplicated');
refreshCurrent();
}
async function bulkTrash(ids) {
for (const id of ids) await del(`/api/v1/nodes/${id}`);
state.selection.clear();
toast(`Moved ${ids.length > 1 ? `${ids.length} items` : 'item'} to trash`, {
actionLabel: 'Undo',
onAction: async () => {
for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});
refreshCurrent(); refreshSidebar();
},
});
refreshCurrent(); refreshSidebar();
}
async function bulkRestore(ids) {
for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});
state.selection.clear();
toast('Restored');
refreshCurrent(); refreshSidebar();
}
async function bulkDeleteForever(ids) {
const ok = await confirmModal({
title: 'Delete forever',
message: `Permanently delete ${ids.length} item(s)? This cannot be undone.`,
confirmLabel: 'Delete forever',
danger: true,
});
if (!ok) return;
for (const id of ids) await del(`/api/v1/nodes/${id}?force=true`);
state.selection.clear();
toast('Deleted forever');
refreshCurrent(); refreshSidebar();
}
function downloadIds(ids) {
if (ids.length === 1) {
const node = nodeById(ids[0]);
if (node?.type === 'file') { location.href = `/dl/${node.id}`; return; }
}
location.href = `/api/v1/zip?ids=${ids.join(',')}`;
}
async function moveIds(ids, targetId) {
let skipped = 0;
for (const id of ids) {
try {
await patch(`/api/v1/nodes/${id}`, { parentId: targetId });
} catch (err) {
skipped += 1;
if (err.status === 409) {
const choice = await conflictDialog(nodeById(id)?.name ?? 'item');
if (choice && choice !== 'skip') {
await patch(`/api/v1/nodes/${id}`, { parentId: targetId, conflict: choice });
skipped -= 1;
}
} else toast(err.message, { error: true });
}
}
state.selection.clear();
toast(`Moved ${ids.length - skipped} item(s)`);
refreshCurrent(); refreshSidebar();
}
function conflictDialog(name) {
return new Promise((resolve) => {
modal({
title: 'Name conflict',
body: h('p', { style: { color: 'var(--muted)' } }, `"${name}" already exists in the destination.`),
onClose: () => resolve(null),
actions: [
{ label: 'Skip', onClick: () => resolve('skip') },
{ label: 'Replace', danger: true, onClick: () => resolve('replace') },
{ label: 'Keep both', primary: true, onClick: () => resolve('keep-both') },
],
});
});
}
function moveDialog(ids) {
const tree = h('div.picker-tree');
let chosen = state.rootId;
const byParent = new Map();
for (const f of state.tree) {
if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
byParent.get(f.parent_id).push(f);
}
const build = (parentId, depth) => {
for (const folder of byParent.get(parentId) ?? []) {
if (ids.includes(folder.id)) continue; // can't move into itself
const row = h('button.nav-item', {
style: { paddingLeft: `${12 + depth * 16}px` },
onclick: (e) => {
tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));
e.currentTarget.classList.add('active');
chosen = folder.id;
},
},
h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
folder.id === state.rootId ? 'My Drive' : folder.name);
tree.append(row);
build(folder.id, depth + 1);
}
};
build(null, 0);
modal({
title: `Move ${ids.length} item(s)`,
body: tree,
actions: [
{ label: 'Cancel', onClick: () => {} },
{ label: 'Copy here', onClick: async () => { for (const id of ids) await post(`/api/v1/nodes/${id}/copy`, { parentId: chosen }); toast('Copied'); refreshCurrent(); refreshSidebar(); } },
{ label: 'Move here', primary: true, onClick: () => moveIds(ids, chosen) },
],
});
}
// ── Tags ─────────────────────────────────────────────────────────────
async function tagDialog(node) {
const { tags } = await getJSON('/api/v1/tags');
const current = new Set((node.tags ?? []).map((t) => t.id));
const list = h('div.tag-editor', {}, tags.map((tag) =>
h(`button.chip${current.has(tag.id) ? '.active' : ''}`, {
onclick: (e) => {
current.has(tag.id) ? current.delete(tag.id) : current.add(tag.id);
e.currentTarget.classList.toggle('active');
},
}, h('span.dot', { style: { background: tag.color } }), tag.name)));
const newInput = h('input', { type: 'text', placeholder: 'New tag name…', style: { marginTop: '12px' } });
newInput.addEventListener('keydown', async (e) => {
if (e.key !== 'Enter' || !newInput.value.trim()) return;
const { tag } = await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });
current.add(tag.id);
list.append(h('button.chip.active', {}, h('span.dot', { style: { background: tag.color } }), tag.name));
newInput.value = '';
});
modal({
title: `Tags — ${node.name}`,
body: h('div', {}, list, newInput),
actions: [
{ label: 'Cancel', onClick: () => {} },
{
label: 'Save', primary: true,
onClick: async () => {
await patch(`/api/v1/nodes/${node.id}`, { tagIds: [...current] });
refreshCurrent(); refreshSidebar();
},
},
],
});
}
function randomTagColor() {
const palette = ['#4f8cff', '#22d3aa', '#7bd88f', '#ffd166', '#ff9f5a', '#ff5d5d', '#b48cff', '#ff7ab8'];
return palette[Math.floor(Math.random() * palette.length)];
}
async function editTagsDialog() {
const { tags } = await getJSON('/api/v1/tags');
const list = h('div');
for (const tag of tags) {
const nameInput = h('input', { type: 'text', value: tag.name, style: { width: '160px' } });
const colorInput = h('input', { type: 'color', value: tag.color, style: { width: '42px', padding: '2px' } });
list.append(h('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },
colorInput, nameInput,
h('button.btn.icon.ghost', {
title: 'Save', html: UI.check,
onclick: async () => { await patch(`/api/v1/tags/${tag.id}`, { name: nameInput.value, color: colorInput.value }); toast('Tag saved'); refreshSidebar(); },
}),
h('button.btn.icon.ghost.danger', {
title: 'Delete', html: UI.trash,
onclick: async () => { await del(`/api/v1/tags/${tag.id}`); toast('Tag deleted'); refreshSidebar(); list.querySelector(`[data-tag="${tag.id}"]`)?.remove(); },
})));
}
const newInput = h('input', { type: 'text', placeholder: 'New tag — press Enter' });
newInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && newInput.value.trim()) {
await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });
refreshSidebar();
toast('Tag created');
newInput.value = '';
}
});
modal({
title: 'Manage tags',
body: h('div', {}, list, newInput),
actions: [{ label: 'Done', primary: true, onClick: () => {} }],
});
}
// ── Info panel ───────────────────────────────────────────────────────
function showInfo(node, force = false) {
state.infoNode = node;
if (force) $('body').classList.add('info-open');
updateInfoPanel();
}
async function updateInfoPanel() {
const panel = $('infoPanel');
const node = state.infoNode;
if (!node) {
panel.innerHTML = '';
$('body').classList.remove('info-open');
return;
}
if (!$('body').classList.contains('info-open')) return;
panel.innerHTML = '';
panel.append(
h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
h('button.btn.icon.ghost', { html: UI.close, onclick: () => { state.infoNode = null; updateInfoPanel(); } })),
h('div.info-thumb', {}, node.type === 'file' && node.hasThumb
? h('img', { src: `/thumb/${node.id}?size=512` })
: h('span', { html: nodeIcon(node), style: { display: 'contents' } })),
h('h3', {}, node.name),
);
const kv = h('dl.kv');
const add = (k, v) => kv.append(h('dt', {}, k), h('dd', {}, v));
add('Type', node.type === 'folder' ? 'Folder' : (node.mime ?? 'file'));
if (node.type === 'file') add('Size', fmtSize(node.size));
add('Created', fmtDate(node.created));
add('Modified', fmtDate(node.modified));
panel.append(kv);
// Tags section
const tagWrap = h('div.tag-editor', {}, (node.tags ?? []).map((t) =>
h('span.chip.active', { style: { borderColor: t.color, color: t.color } }, t.name)));
tagWrap.append(h('button.chip', { onclick: () => tagDialog(node) }, '+ edit'));
panel.append(h('div.info-section', {}, h('h4', {}, 'Tags'), tagWrap));
// Shares section
try {
const { shares } = await getJSON(`/api/v1/nodes/${node.id}/shares`);
const wrap = h('div');
for (const share of shares) {
wrap.append(h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },
h('span.mono', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, `/s/${share.token}`),
h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy URL', onclick: () => copyText(share.url, 'Share URL copied') }),
h('button.btn.icon.ghost.danger', { html: UI.close, title: 'Revoke', onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Share revoked'); updateInfoPanel(); } })));
}
wrap.append(h('button.btn', { style: { marginTop: '4px' }, onclick: () => shareDialog(node) },
h('span', { html: UI.share, style: { display: 'contents' } }), 'New share link'));
panel.append(h('div.info-section', {}, h('h4', {}, `Shares (${shares.length})`), wrap));
} catch {}
// Version history section (files only)
if (node.type === 'file') {
try {
const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);
const wrap = h('div');
if (!versions.length) {
wrap.append(h('p.muted', { style: { fontSize: '12px', margin: 0 } },
'No previous versions yet — replacing or editing this file keeps its history here.'));
}
for (const v of versions.slice(0, 5)) wrap.append(versionRow(node, v, () => updateInfoPanel()));
if (versions.length > 5) {
wrap.append(h('button.btn', { style: { marginTop: '6px' }, onclick: () => versionsDialog(node) },
`All ${versions.length} versions…`));
}
panel.append(h('div.info-section', {}, h('h4', {}, `Versions (${versions.length})`), wrap));
} catch {}
}
}
// ── Version history ──────────────────────────────────────────────────
function versionRow(node, v, onChange) {
return h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },
h('div', { style: { flex: 1, minWidth: 0 } },
h('div', {}, `${fmtDate(v.replacedAt)} · ${fmtSize(v.size)}`),
h('div', { style: { color: 'var(--muted)', fontSize: '11px' } }, ({
replace: 'replaced by upload', edit: 'edited in browser', restore: 'superseded by restore',
})[v.origin] ?? v.origin)),
h('a.btn.icon.ghost', { html: UI.download, title: 'Download this version', href: `/api/v1/nodes/${node.id}/versions/${v.id}/dl` }),
h('button.btn.icon.ghost', {
html: UI.restore, title: 'Restore this version',
onclick: async () => {
await post(`/api/v1/nodes/${node.id}/versions/${v.id}/restore`, {});
toast('Version restored — current content kept in history');
refreshCurrent();
onChange?.();
},
}),
h('button.btn.icon.ghost.danger', {
html: UI.close, title: 'Delete this version',
onclick: async () => {
await del(`/api/v1/nodes/${node.id}/versions/${v.id}`);
toast('Version deleted');
onChange?.();
},
}));
}
async function versionsDialog(node) {
const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);
const list = h('div', { style: { maxHeight: '55vh', overflow: 'auto' } });
const rerender = async () => {
const fresh = await getJSON(`/api/v1/nodes/${node.id}/versions`);
list.innerHTML = '';
if (!fresh.versions.length) list.append(h('p.muted', {}, 'No previous versions.'));
for (const v of fresh.versions) list.append(versionRow(node, v, rerender));
};
if (!versions.length) list.append(h('p.muted', {}, 'No previous versions.'));
for (const v of versions) list.append(versionRow(node, v, rerender));
modal({
title: `Version history — ${node.name}`,
body: list,
actions: [{ label: 'Close', primary: true, onClick: () => {} }],
});
}
// ── Share dialog & manager ───────────────────────────────────────────
function shareDialog(node) {
const expiry = h('select', {},
...[['', 'Never'], ['3600000', '1 hour'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]
.map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));
const password = h('input', { type: 'text', placeholder: 'Optional password', autocomplete: 'off' });
const maxDl = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });
const allowDl = h('input', { type: 'checkbox', checked: true });
const label = h('input', { type: 'text', placeholder: 'Note to self (optional)' });
const result = h('div');
modal({
title: `Share — ${node.name}`,
body: h('div', {},
h('label.field', {}, h('span', {}, 'Expires'), expiry),
h('label.field', {}, h('span', {}, 'Password'), password),
h('label.field', {}, h('span', {}, 'Max downloads'), maxDl),
h('label.field', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, allowDl, h('span', { style: { margin: 0 } }, 'Allow download (off = preview only)')),
h('label.field', {}, h('span', {}, 'Label'), label),
result),
actions: [
{ label: 'Close', onClick: () => {} },
{
label: 'Create link', primary: true,
onClick: async () => {
const { share } = await post('/api/v1/shares', {
nodeId: node.id,
expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,
password: password.value || null,
maxDownloads: maxDl.value ? Number(maxDl.value) : null,
allowDownload: allowDl.checked,
label: label.value || null,
});
result.innerHTML = '';
result.append(
h('div.share-url', {},
h('input', { type: 'text', value: share.url, readonly: true, onclick: (e) => e.target.select() }),
h('button.btn.primary', { onclick: () => copyText(share.url, 'Share URL copied') }, 'Copy')),
h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 160, height: 160, alt: 'QR code' })),
);
updateInfoPanel();
return false; // keep modal open to show the URL
},
},
],
});
}
async function loadShares() {
const { shares } = await getJSON('/api/v1/shares');
state.nodes = [];
toolbar.innerHTML = '';
toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, `Shared links (${shares.filter((s) => !s.revokedAt).length} active)`)));
content.innerHTML = '';
if (!shares.length) {
content.append(emptyState('search', 'No share links yet', 'Right-click any file or folder → Share'));
return;
}
const table = h('table.shares-table', {},
h('thead', {}, h('tr', {},
...['Item', 'Link', 'Visits', 'Downloads', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));
const tbody = h('tbody');
for (const share of shares) {
const expired = share.revokedAt || (share.expiresAt && share.expiresAt < Date.now());
const expiry = share.revokedAt ? 'revoked'
: !share.expiresAt ? 'never'
: share.expiresAt < Date.now() ? 'expired'
: countdown(share.expiresAt);
tbody.append(h('tr', { style: expired ? { opacity: 0.55 } : {} },
h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
h('span', { html: share.nodeType === 'folder' ? folderIcon({}) : fileIcon('file'), style: { display: 'contents' } }),
h('span', {}, share.nodeName),
share.label ? h('span.chip', {}, share.label) : '')),
h('td', {}, h('div.url-cell', {},
h('span', {}, `/s/${share.token}`),
h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(share.url, 'URL copied') }),
h('button.btn.icon.ghost', { html: UI.qr, title: 'QR code', onclick: () => showQr(share) }))),
h('td', {}, String(share.visits)),
h('td', {}, `${share.downloads}${share.maxDownloads ? ` / ${share.maxDownloads}` : ''}`),
h('td', {}, expiry),
h('td', {}, h('span.chip', { style: expired ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },
expired ? 'inactive' : share.hasPassword ? '🔒 active' : 'active')),
h('td', {}, h('div', { style: { display: 'flex', gap: '4px' } },
h('button.btn.icon.ghost', { html: UI.activity, title: 'Visit log', onclick: () => showShareEvents(share) }),
!share.revokedAt ? h('button.btn.icon.ghost.danger', {
html: UI.close, title: 'Revoke',
onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Revoked'); loadShares(); },
}) : '')),
));
}
table.append(tbody);
content.append(table);
}
function countdown(ts) {
const diff = ts - Date.now();
if (diff < 3_600_000) return `${Math.max(1, Math.round(diff / 60_000))} min`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} h`;
return `${Math.round(diff / 86_400_000)} d`;
}
function showQr(share) {
modal({
title: 'QR code',
body: h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 220, height: 220, alt: 'QR code' }),
h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, share.url)),
actions: [{ label: 'Close', primary: true, onClick: () => {} }],
});
}
async function showShareEvents(share) {
const { events } = await getJSON(`/api/v1/shares/${share.id}/events`);
const list = h('div', { style: { maxHeight: '50vh', overflow: 'auto' } });
if (!events.length) list.append(h('p.muted', {}, 'No visits yet.'));
for (const ev of events) {
list.append(h('div.act-row', {},
h('span', { html: ev.kind === 'download' ? UI.download : UI.eye, style: { display: 'contents' } }),
h('div', {}, h('div', {}, `${ev.kind} · ${ev.ip || 'unknown ip'}`), h('div.ua', { style: { color: 'var(--muted)', fontSize: '11px' } }, ev.ua ?? '')),
h('span.when', {}, fmtDate(ev.ts))));
}
modal({ title: `Visits — /s/${share.token}`, body: list, wide: true, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });
}
// ── File requests (receive files) ────────────────────────────────────
function folderPickerTree(onPick, preselect) {
const tree = h('div.picker-tree');
const byParent = new Map();
for (const f of state.tree) {
if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
byParent.get(f.parent_id).push(f);
}
const build = (parentId, depth) => {
for (const folder of byParent.get(parentId) ?? []) {
const row = h(`button.nav-item${folder.id === preselect ? '.active' : ''}`, {
style: { paddingLeft: `${12 + depth * 16}px` },
onclick: (e) => {
tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));
e.currentTarget.classList.add('active');
onPick(folder.id);
},
},
h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
folder.id === state.rootId ? 'My Drive' : folder.name);
tree.append(row);
build(folder.id, depth + 1);
}
};
build(null, 0);
return tree;
}
function newRequestDialog(folderId = state.rootId) {
let chosen = folderId;
const tree = folderPickerTree((id) => { chosen = id; }, folderId);
const label = h('input', { type: 'text', placeholder: 'What are you asking for? (shown to the sender)' });
const expiry = h('select', {},
...[['', 'Never'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]
.map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));
const maxFiles = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });
const result = h('div');
modal({
title: 'Request files from someone',
body: h('div', {},
h('p', { style: { color: 'var(--muted)', fontSize: '12.5px', margin: '0 0 12px' } },
'Anyone with the link can upload into the chosen folder — they never see its contents.'),
h('label.field', {}, h('span', {}, 'Destination folder'), tree),
h('label.field', {}, h('span', {}, 'Note to sender'), label),
h('label.field', {}, h('span', {}, 'Expires'), expiry),
h('label.field', {}, h('span', {}, 'Max files'), maxFiles),
result),
actions: [
{ label: 'Close', onClick: () => {} },
{
label: 'Create link', primary: true,
onClick: async () => {
const { request } = await post('/api/v1/requests', {
folderId: chosen,
label: label.value || null,
expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,
maxFiles: maxFiles.value ? Number(maxFiles.value) : null,
});
result.innerHTML = '';
result.append(
h('div.share-url', {},
h('input', { type: 'text', value: request.url, readonly: true, onclick: (e) => e.target.select() }),
h('button.btn.primary', { onclick: () => copyText(request.url, 'Request URL copied') }, 'Copy')),
h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${request.id}/qr`, width: 160, height: 160, alt: 'QR code' })),
);
if (state.route.view === 'requests') loadRequests();
return false; // keep open to show the URL
},
},
],
});
}
async function loadRequests() {
const { requests } = await getJSON('/api/v1/requests');
state.nodes = [];
toolbar.innerHTML = '';
toolbar.append(
h('div.crumbs', {}, h('span.crumb.current', {}, `File requests (${requests.filter((r) => !r.closedAt).length} active)`)),
h('button.btn.primary', { onclick: () => newRequestDialog(state.rootId) }, '+ New request'),
);
content.innerHTML = '';
if (!requests.length) {
content.append(emptyState('search', 'No file requests yet', 'Create a link that lets someone send files straight into a folder'));
return;
}
const table = h('table.shares-table', {},
h('thead', {}, h('tr', {},
...['Folder', 'Link', 'Note', 'Received', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));
const tbody = h('tbody');
for (const r of requests) {
const dead = r.closedAt || (r.expiresAt && r.expiresAt < Date.now())
|| (r.maxFiles && r.received >= r.maxFiles);
tbody.append(h('tr', { style: dead ? { opacity: 0.55 } : {} },
h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
h('span', { html: folderIcon({}), style: { display: 'contents' } }),
h('button.crumb', { onclick: () => go(`folder/${r.folderId}`) }, r.folderName || 'My Drive'))),
h('td', {}, h('div.url-cell', {},
h('span', {}, `/r/${r.token}`),
h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(r.url, 'URL copied') }),
h('button.btn.icon.ghost', {
html: UI.qr, title: 'QR code',
onclick: () => modal({
title: 'QR code',
body: h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${r.id}/qr`, width: 220, height: 220, alt: 'QR code' }),
h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, r.url)),
actions: [{ label: 'Close', primary: true, onClick: () => {} }],
}),
}))),
h('td', {}, r.label ?? '—'),
h('td', {}, `${r.received}${r.maxFiles ? ` / ${r.maxFiles}` : ''}`),
h('td', {}, r.closedAt ? 'closed' : !r.expiresAt ? 'never' : r.expiresAt < Date.now() ? 'expired' : countdown(r.expiresAt)),
h('td', {}, h('span.chip', { style: dead ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },
dead ? 'inactive' : 'active')),
h('td', {}, !r.closedAt ? h('button.btn.icon.ghost.danger', {
html: UI.close, title: 'Close link',
onclick: async () => { await del(`/api/v1/requests/${r.id}`); toast('Request closed'); loadRequests(); },
}) : ''),
));
}
table.append(tbody);
content.append(table);
}
// ── Storage insights ─────────────────────────────────────────────────
async function loadStorage() {
toolbar.innerHTML = '';
toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Storage')));
content.innerHTML = '';
const s = await getJSON('/api/v1/stats/detailed');
content.innerHTML = '';
const stat = (label, value, sub) => h('div.stat-tile', {},
h('div.v', {}, value), h('div.l', {}, label), sub ? h('div.s', {}, sub) : '');
content.append(h('div.stat-row', {},
stat('Used space', fmtSize(s.usedBytes), `${s.nodeCount} items · ${s.blobCount} unique blobs`),
stat('Saved by dedup', fmtSize(s.dedupSavedBytes), 'identical content stored once'),
stat('Version history', fmtSize(s.versionBytes), `${s.versionCount} kept version(s)`),
stat('In trash', fmtSize(s.trashBytes), `${s.trashCount} file(s) — auto-purged after 30 days`),
));
// Largest files
const largestWrap = h('div.panel', {}, h('h3', {}, 'Largest files'));
const maxSize = s.largest[0]?.size || 1;
for (const f of s.largest) {
largestWrap.append(h('div.big-file', {
onclick: () => go(`folder/${f.parentId}`), title: `Open ${f.path}`,
},
h('span', { html: nodeIcon(f), style: { display: 'contents' } }),
h('div.bf-main', {},
h('div.bf-name', {}, f.name, h('span.bf-path', {}, ` ${f.path.slice(0, f.path.lastIndexOf('/') + 1)}`)),
h('div.bf-bar', {}, h('i', { style: { width: `${(f.size / maxSize) * 100}%` } }))),
h('span.bf-size', {}, fmtSize(f.size))));
}
if (!s.largest.length) largestWrap.append(h('p.muted', {}, 'No files yet.'));
// Duplicates
const dupWrap = h('div.panel', {}, h('h3', {}, 'Duplicate files'),
h('p.muted', { style: { fontSize: '12.5px', margin: '0 0 10px' } },
'Copies share one blob on disk (no wasted space) — listed here so you can tidy up.'));
if (!s.duplicates.length) dupWrap.append(h('p.muted', {}, 'No duplicates — nice and tidy.'));
for (const g of s.duplicates) {
const group = h('div.dup-group', {},
h('div.dup-head', {},
h('strong', {}, `${g.copies} copies`), ` · ${fmtSize(g.size)} each`,
h('span.mono', { style: { color: 'var(--muted)', fontSize: '10.5px', marginLeft: '8px' } }, g.sha.slice(0, 12))));
for (const n of g.nodes) {
group.append(h('div.dup-row', {},
h('button.crumb', { onclick: () => go(`folder/${n.parentId}`) }, n.path),
h('button.btn.icon.ghost.danger', {
html: UI.trash, title: 'Move this copy to trash',
onclick: async () => { await del(`/api/v1/nodes/${n.id}`); toast('Moved to trash'); loadStorage(); },
})));
}
dupWrap.append(group);
}
content.append(h('div.storage-page', {}, largestWrap, dupWrap));
}
// ── Activity ─────────────────────────────────────────────────────────
const ACT_ICONS = {
'file.upload': UI.upload, 'file.download_zip': UI.download, 'folder.create': UI.folderNew,
'node.rename': UI.rename, 'node.move': UI.move, 'node.copy': UI.copy, 'node.trash': UI.trash,
'node.restore': UI.restore, 'node.delete_forever': UI.trash, 'node.star': UI.starO,
'share.create': UI.share, 'share.visit': UI.eye, 'share.download': UI.download,
'share.revoke': UI.close, 'auth.login': UI.check, 'auth.login_failed': UI.close,
'file.edit': UI.edit, 'file.replace': UI.upload, 'file.restore': UI.history,
'request.create': UI.inbox, 'request.upload': UI.inbox, 'request.close': UI.close,
};
async function loadActivity() {
const { events } = await getJSON('/api/v1/activity?limit=300');
toolbar.innerHTML = '';
toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Activity')));
content.innerHTML = '';
const list = h('div.activity-list');
for (const ev of events) {
list.append(h('div.act-row', {},
h('span', { html: ACT_ICONS[ev.kind] ?? UI.activity, style: { display: 'contents' } }),
h('div', {},
h('div', {}, `${ev.kind.replace(/[._]/g, ' ')}${ev.node_name ? ` — ${ev.node_name}` : ''}`),
ev.detail || ev.ip ? h('div', { style: { color: 'var(--muted)', fontSize: '11.5px' } }, [ev.detail, ev.ip].filter(Boolean).join(' · ')) : ''),
h('span.when', {}, fmtDate(ev.ts))));
}
content.append(list.children.length ? list : emptyState('search', 'No activity yet', ''));
}
// ── Settings ─────────────────────────────────────────────────────────
async function loadSettings() {
toolbar.innerHTML = '';
toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Settings')));
content.innerHTML = '';
const wrap = h('div.settings');
// Change password
const cur = h('input', { type: 'password', autocomplete: 'current-password' });
const next = h('input', { type: 'password', autocomplete: 'new-password' });
const next2 = h('input', { type: 'password', autocomplete: 'new-password' });
wrap.append(h('div.panel', {},
h('h3', {}, 'Change password'),
h('label.field', {}, h('span', {}, 'Current password'), cur),
h('label.field', {}, h('span', {}, 'New password'), next),
h('label.field', {}, h('span', {}, 'Repeat new password'), next2),
h('button.btn.primary', {
onclick: async () => {
if (next.value !== next2.value) { toast('Passwords do not match', { error: true }); return; }
try {
await post('/api/v1/auth/password', { current: cur.value, next: next.value });
toast('Password changed');
cur.value = next.value = next2.value = '';
} catch (err) { toast(err.message, { error: true }); }
},
}, 'Update password')));
// Sessions
const sessionsPanel = h('div.panel', {}, h('h3', {}, 'Active sessions'));
const renderSessions = async () => {
[...sessionsPanel.querySelectorAll('.session-row, .btn.danger')].forEach((n) => n.remove());
const { sessions } = await getJSON('/api/v1/auth/sessions');
for (const s of sessions) {
sessionsPanel.append(h('div.session-row', {},
h('div.who', {},
h('div', {}, `${s.ip || 'unknown ip'} ${s.remember ? '· remembered' : ''}`),
h('div.ua', {}, s.ua ?? '')),
h('span.muted', { style: { fontSize: '11.5px' } }, `seen ${fmtDate(s.last_seen)}`),
h('button.btn.icon.ghost.danger', {
html: UI.close, title: 'Revoke',
onclick: async () => { await del(`/api/v1/auth/sessions/${s.id}`); renderSessions(); },
})));
}
sessionsPanel.append(h('button.btn.danger', {
style: { marginTop: '12px' },
onclick: async () => {
if (await confirmModal({ title: 'Logout everywhere', message: 'Revoke every session including this one?', confirmLabel: 'Logout everywhere', danger: true })) {
await del('/api/v1/auth/sessions');
location.href = '/login';
}
},
}, 'Logout everywhere'));
};
renderSessions();
wrap.append(sessionsPanel);
// API tokens
const tokensPanel = h('div.panel', {}, h('h3', {}, 'API tokens (CLI)'));
const renderTokens = async () => {
[...tokensPanel.querySelectorAll('.session-row, .tok-new')].forEach((n) => n.remove());
const { tokens } = await getJSON('/api/v1/auth/api-tokens');
for (const t of tokens) {
tokensPanel.append(h('div.session-row', {},
h('div.who', {}, h('div', {}, t.name), h('div.ua', {}, `created ${fmtDate(t.created)} · last used ${fmtDate(t.last_used)}`)),
h('button.btn.icon.ghost.danger', {
html: UI.close, title: 'Revoke',
onclick: async () => { await del(`/api/v1/auth/api-tokens/${t.id}`); renderTokens(); },
})));
}
const nameInput = h('input', { type: 'text', placeholder: 'Token name (e.g. laptop-cli)', style: { width: '220px' } });
tokensPanel.append(h('div.tok-new', { style: { display: 'flex', gap: '8px', marginTop: '12px' } },
nameInput,
h('button.btn.primary', {
onclick: async () => {
const { token } = await post('/api/v1/auth/api-tokens', { name: nameInput.value || 'token' });
modal({
title: 'API token created',
body: h('div', {},
h('p', { style: { color: 'var(--muted)', fontSize: '13px' } }, 'Copy it now — it will not be shown again. Use it with `spbdrive init`.'),
h('div.share-url', {},
h('input', { type: 'text', value: token, readonly: true, onclick: (e) => e.target.select() }),
h('button.btn.primary', { onclick: () => copyText(token, 'Token copied') }, 'Copy'))),
actions: [{ label: 'Done', primary: true, onClick: () => {} }],
});
renderTokens();
},
}, 'Create token')));
};
renderTokens();
wrap.append(tokensPanel);
// Appearance + logout
wrap.append(h('div.panel', {},
h('h3', {}, 'Appearance'),
h('button.btn', { onclick: toggleTheme }, 'Toggle dark / light theme'),
h('span.muted', { style: { marginLeft: '10px', fontSize: '12px' } }, 'Preference is saved in this browser.')));
wrap.append(h('div.panel', {},
h('h3', {}, 'Session'),
h('a.btn', { href: '/logout' }, h('span', { html: UI.logout, style: { display: 'contents' } }), 'Log out')));
content.append(wrap);
}
// ── Rectangle select ─────────────────────────────────────────────────
function wireRectangleSelect(host) {
let start = null; let rect = null;
content.onpointerdown = (e) => {
if (e.button !== 0 || e.target.closest('[data-id], button, input, a, select')) return;
start = { x: e.clientX, y: e.clientY };
if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
state.selection.clear();
paintSelection();
}
};
content.onpointermove = (e) => {
if (!start) return;
if (!rect) {
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < 6) return;
rect = h('div.select-rect');
document.body.append(rect);
}
const x = Math.min(start.x, e.clientX); const y = Math.min(start.y, e.clientY);
const w = Math.abs(e.clientX - start.x); const hgt = Math.abs(e.clientY - start.y);
Object.assign(rect.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${hgt}px`, position: 'fixed' });
const box = { left: x, top: y, right: x + w, bottom: y + hgt };
host.querySelectorAll('[data-id]').forEach((el) => {
const r = el.getBoundingClientRect();
const hit = !(r.right < box.left || r.left > box.right || r.bottom < box.top || r.top > box.bottom);
const id = Number(el.dataset.id);
hit ? state.selection.add(id) : state.selection.delete(id);
});
paintSelection();
};
const end = () => { start = null; rect?.remove(); rect = null; };
content.onpointerup = end;
content.onpointerleave = end;
}
// ── Keyboard shortcuts ───────────────────────────────────────────────
function onGlobalKey(e) {
const inInput = e.target.matches('input, textarea, select, [contenteditable]');
if (e.key === '/' && !inInput) { e.preventDefault(); $('searchInput').focus(); return; }
if (inInput) return;
if (document.querySelector('.preview-overlay, .modal-scrim, .ctx-menu')) return;
const focusedId = state.anchor;
const node = focusedId ? nodeById(focusedId) : null;
if (e.key === '?') { showShortcuts(); return; }
if (e.key === 'Enter' && node) { openNode(node); return; }
if (e.key === ' ' && node && node.type === 'file') { e.preventDefault(); openViewer(node); return; }
if (e.key === 'F2' && node) { e.preventDefault(); inlineRename(node); return; }
if ((e.key === 'Delete' || e.key === 'Backspace') && state.selection.size) {
e.preventDefault();
state.route.view === 'trash' ? bulkDeleteForever([...state.selection]) : bulkTrash([...state.selection]);
return;
}
if (e.key.toLowerCase() === 's' && node && !e.metaKey && !e.ctrlKey) { toggleStar(node); return; }
if (e.key.toLowerCase() === 'e' && node && !e.metaKey && !e.ctrlKey && isEditable(node)) { openEditor(node); return; }
if (e.key.toLowerCase() === 'a' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
state.selection = new Set(state.nodes.map((n) => n.id));
paintSelection();
return;
}
if (e.key.toLowerCase() === 'v' && !e.metaKey && !e.ctrlKey) { toggleViewMode(); return; }
if (e.key.toLowerCase() === 'n' && !e.metaKey && !e.ctrlKey && state.route.view === 'folder') { newFolderDialog(); return; }
// Arrow navigation
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) && state.nodes.length) {
e.preventDefault();
const ids = state.nodes.map((n) => n.id);
let idx = focusedId ? ids.indexOf(focusedId) : -1;
const cols = state.viewMode === 'grid'
? Math.max(1, Math.floor(content.querySelector('.grid')?.clientWidth / (state.cardSize + 12)) || 1)
: 1;
const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key];
idx = Math.min(Math.max(idx + delta, 0), ids.length - 1);
const id = ids[idx];
state.anchor = id;
if (e.shiftKey) state.selection.add(id);
else state.selection = new Set([id]);
paintSelection();
content.querySelector(`[data-id="${id}"]`)?.scrollIntoView({ block: 'nearest' });
const n = nodeById(id);
if (n) showInfo(n);
}
}
function showShortcuts() {
const rows = [
['Navigate', '← → ↑ ↓'], ['Open / enter folder', '↵'], ['Quick look', 'Space'],
['Rename', 'F2'], ['Move to trash', 'Del'], ['Select all', '⌘A'],
['Extend selection', 'Shift+click'], ['Toggle item', '⌘+click'],
['Star', 'S'], ['Edit text file', 'E'], ['Toggle view', 'V'], ['New folder', 'N'],
['Search', '/'], ['This cheat sheet', '?'], ['Close / cancel', 'Esc'],
];
modal({
title: 'Keyboard shortcuts',
wide: true,
body: h('div.shortcuts-grid', {}, rows.map(([label, key]) =>
h('div', {}, h('span', {}, label), h('kbd', {}, key)))),
actions: [{ label: 'Close', primary: true, onClick: () => {} }],
});
}
boot();