spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/assets/js/app.js8 * Purpose : Main SPA — routing, browsing, selection, dnd, uploads, shares,9 * search, trash, tags, settings, activity, keyboard shortcuts10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { getJSON, post, patch, del } from './api.js';15import {16 h, fmtSize, fmtDate, toast, modal, confirmModal, contextMenu, copyText,17} from './ui.js';18import { UI, nodeIcon, folderIcon, fileIcon, EMPTY_ART } from './icons.js';19import { UploadManager, collectDropped, bindPasteUpload } from './upload.js';20import { Viewer } from './viewer.js';21import { Editor, isEditable } from './editor.js';2223const FOLDER_COLORS = ['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'purple', 'pink'];2425const state = {26 rootId: 1,27 route: { view: 'folder', id: 1 },28 nodes: [], // nodes in current view29 path: [],30 selection: new Set(),31 anchor: null, // shift-select anchor id32 sort: 'name',33 dir: 'asc',34 viewMode: localStorage.getItem('spbdrive-view') ?? 'grid',35 viewModes: JSON.parse(localStorage.getItem('spbdrive-view-per-folder') ?? '{}'),36 cardSize: Number(localStorage.getItem('spbdrive-card') ?? 168),37 tree: [],38 tags: [],39 infoNode: null,40 clipboard: null, // {ids, cut}41};4243const $ = (id) => document.getElementById(id);44const content = $('content');45const toolbar = $('toolbar');46const IS_TOUCH = window.matchMedia('(pointer: coarse)').matches;4748// ── Boot ─────────────────────────────────────────────────────────────49// One conflict answer can apply to the whole upload batch.50let batchConflictChoice = null;51let conflictQueue = Promise.resolve();5253function askUploadConflict(item, existing) {54 const run = async () => {55 if (batchConflictChoice) return batchConflictChoice;56 return new Promise((resolve) => {57 const applyAll = h('input', { type: 'checkbox' });58 modal({59 title: 'File already exists',60 body: h('div', {},61 h('p', { style: { color: 'var(--muted)', margin: '0 0 10px' } },62 `"${existing.name}" (${fmtSize(existing.size)}) is already in this folder.`),63 h('p', { style: { fontSize: '12.5px', margin: '0 0 10px' } },64 'Replace keeps the old content in the file’s version history.'),65 h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },66 applyAll, 'Apply to all remaining conflicts')),67 onClose: () => resolve('skip'),68 actions: [69 { label: 'Skip', onClick: () => { if (applyAll.checked) batchConflictChoice = 'skip'; resolve('skip'); } },70 { label: 'Replace', danger: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'replace'; resolve('replace'); } },71 { label: 'Keep both', primary: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'keep-both'; resolve('keep-both'); } },72 ],73 });74 });75 };76 conflictQueue = conflictQueue.then(run, run);77 return conflictQueue;78}7980const uploads = new UploadManager({81 onFinished: () => { batchConflictChoice = null; refreshCurrent(); refreshSidebar(); },82 resolveConflict: askUploadConflict,83});8485async function boot() {86 try {87 const me = await getJSON('/api/v1/me');88 state.rootId = me.rootId;89 } catch { return; }90 wireChrome();91 bindPasteUpload(uploads, () => currentFolderId());92 await refreshSidebar();93 window.addEventListener('hashchange', onRoute);94 onRoute();95}9697function currentFolderId() {98 return state.route.view === 'folder' ? state.route.id : state.rootId;99}100101// ── Routing ──────────────────────────────────────────────────────────102function onRoute() {103 const hash = location.hash.slice(2) || `folder/${state.rootId}`;104 const [view, arg] = hash.split('/');105 const q = new URLSearchParams(hash.split('?')[1] ?? '');106 state.route = { view: view.split('?')[0], id: Number(arg) || arg, q };107 state.selection.clear();108 state.infoNode = null;109 updateInfoPanel();110 const views = {111 folder: () => loadFolder(Number(state.route.id) || state.rootId),112 recent: loadRecent,113 starred: loadStarred,114 shared: loadShares,115 trash: loadTrash,116 tag: () => loadTag(Number(state.route.id)),117 search: () => loadSearch(decodeURIComponent(String(state.route.id ?? ''))),118 activity: loadActivity,119 requests: loadRequests,120 storage: loadStorage,121 settings: loadSettings,122 };123 (views[state.route.view] ?? views.folder)();124 highlightSidebar();125 toggleDrawer(false);126}127128const go = (hash) => { location.hash = `#/${hash}`; };129130// ── Chrome (topbar, sidebar skeleton) ────────────────────────────────131function wireChrome() {132 $('sidebarToggle').innerHTML = UI.menu;133 $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;134 $('themeToggle').innerHTML = document.documentElement.dataset.theme === 'light' ? UI.moon : UI.sun;135 $('settingsBtn').innerHTML = UI.gear;136137 $('sidebarToggle').onclick = () => toggleDrawer();138 $('sidebarScrim').onclick = () => toggleDrawer(false);139 $('uploadBtn').onclick = () => pickFiles();140 $('viewToggle').onclick = toggleViewMode;141 $('themeToggle').onclick = toggleTheme;142 $('settingsBtn').onclick = () => go('settings');143 $('newBtn').onclick = (e) => {144 const r = e.currentTarget.getBoundingClientRect();145 contextMenu(r.left, r.bottom + 4, newMenuItems());146 };147148 // Bottom navigation (mobile)149 const bn = $('bottomNav');150 const bnIcons = { drive: UI.move, search: UI.search, new: UI.plus, shared: UI.link, menu: UI.menu };151 bn.querySelectorAll('button').forEach((b) => { b.innerHTML = bnIcons[b.dataset.bn]; });152 bn.addEventListener('click', (e) => {153 const btn = e.target.closest('button');154 if (!btn) return;155 const k = btn.dataset.bn;156 if (k === 'drive') go(`folder/${state.rootId}`);157 else if (k === 'search') $('searchInput').focus();158 else if (k === 'new') contextMenu(window.innerWidth / 2, window.innerHeight - 90, newMenuItems());159 else if (k === 'shared') go('shared');160 else if (k === 'menu') toggleDrawer();161 });162163 $('filePick').onchange = (e) => {164 uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.name })), currentFolderId());165 e.target.value = '';166 };167 $('folderPick').onchange = (e) => {168 uploads.add([...e.target.files].map((f) => ({ file: f, relPath: f.webkitRelativePath || f.name })), currentFolderId());169 e.target.value = '';170 };171172 // Global search box173 const search = $('searchInput');174 let debounce;175 search.addEventListener('input', () => {176 clearTimeout(debounce);177 debounce = setTimeout(() => {178 if (search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);179 }, 350);180 });181 search.addEventListener('keydown', (e) => {182 if (e.key === 'Enter' && search.value.trim()) go(`search/${encodeURIComponent(search.value.trim())}`);183 if (e.key === 'Escape') search.blur();184 });185186 // Full-window drag & drop187 let dragDepth = 0;188 window.addEventListener('dragenter', (e) => {189 if (![...e.dataTransfer?.types ?? []].includes('Files')) return;190 dragDepth += 1;191 $('dropOverlay').classList.add('active');192 });193 window.addEventListener('dragleave', () => {194 dragDepth = Math.max(0, dragDepth - 1);195 if (!dragDepth) $('dropOverlay').classList.remove('active');196 });197 window.addEventListener('dragover', (e) => e.preventDefault());198 window.addEventListener('drop', async (e) => {199 e.preventDefault();200 dragDepth = 0;201 $('dropOverlay').classList.remove('active');202 if (!e.dataTransfer?.files?.length && !e.dataTransfer?.items?.length) return;203 const items = await collectDropped(e.dataTransfer);204 if (items.length) uploads.add(items, currentFolderId());205 });206207 document.addEventListener('keydown', onGlobalKey);208}209210function pickFiles() { $('filePick').click(); }211212function newMenuItems() {213 return [214 { label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },215 { label: 'New text file', icon: UI.edit, onClick: newTextFileDialog },216 { sep: true },217 { label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },218 { label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },219 { sep: true },220 { label: 'Request files from someone…', icon: UI.inbox, onClick: () => newRequestDialog(currentFolderId()) },221 ];222}223224/** Mobile sidebar drawer open/close (with scrim). */225function toggleDrawer(open) {226 const willOpen = open ?? !$('sidebar').classList.contains('open');227 $('sidebar').classList.toggle('open', willOpen);228 $('sidebarScrim').classList.toggle('active', willOpen);229}230231function toggleViewMode() {232 state.viewMode = state.viewMode === 'grid' ? 'list' : 'grid';233 localStorage.setItem('spbdrive-view', state.viewMode);234 if (state.route.view === 'folder') {235 state.viewModes[state.route.id] = state.viewMode;236 localStorage.setItem('spbdrive-view-per-folder', JSON.stringify(state.viewModes));237 }238 $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;239 renderNodes();240}241242function toggleTheme() {243 const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';244 document.documentElement.dataset.theme = next;245 localStorage.setItem('spbdrive-theme', next);246 $('themeToggle').innerHTML = next === 'light' ? UI.moon : UI.sun;247 document.getElementById('metaTheme')?.setAttribute('content', next === 'dark' ? '#131210' : '#f6f5f1');248}249250// ── Sidebar ──────────────────────────────────────────────────────────251async function refreshSidebar() {252 const [treeRes, tagsRes, stats] = await Promise.all([253 getJSON('/api/v1/tree'), getJSON('/api/v1/tags'), getJSON('/api/v1/stats'),254 ]);255 state.tree = treeRes.folders;256 state.tags = tagsRes.tags;257258 const sections = $('navSections');259 sections.innerHTML = '';260 const navs = [261 ['My Drive', UI.move, `folder/${state.rootId}`],262 ['Recent', UI.clock, 'recent'],263 ['Starred', UI.starO, 'starred'],264 ['Shared', UI.link, 'shared'],265 ['File requests', UI.inbox, 'requests'],266 ['Storage', UI.storage, 'storage'],267 ['Activity', UI.activity, 'activity'],268 ['Trash', UI.trash, 'trash'],269 ];270 for (const [label, icon, hash] of navs) {271 sections.append(h('button.nav-item', { dataset: { nav: hash }, onclick: () => go(hash) },272 h('span', { html: icon, style: { display: 'contents' } }), label));273 }274275 renderTree();276 renderTagList();277 renderStorageMeter(stats);278 highlightSidebar();279}280281const expanded = new Set(JSON.parse(localStorage.getItem('spbdrive-expanded') ?? '[1]'));282283function renderTree() {284 const host = $('folderTree');285 host.innerHTML = '';286 const byParent = new Map();287 for (const f of state.tree) {288 if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);289 byParent.get(f.parent_id).push(f);290 }291 const build = (parentId, depth) => {292 const frag = document.createDocumentFragment();293 for (const folder of byParent.get(parentId) ?? []) {294 const kids = byParent.get(folder.id) ?? [];295 const row = h('div.tree-row', {296 dataset: { folderId: folder.id, nav: `folder/${folder.id}` },297 style: { paddingLeft: `${depth * 14}px` },298 });299 const toggle = h(`button.tree-toggle${expanded.has(folder.id) ? '.open' : ''}`, {300 html: kids.length ? UI.chevron : '',301 tabindex: kids.length ? 0 : -1,302 onclick: (e) => {303 e.stopPropagation();304 expanded.has(folder.id) ? expanded.delete(folder.id) : expanded.add(folder.id);305 localStorage.setItem('spbdrive-expanded', JSON.stringify([...expanded]));306 renderTree();307 },308 });309 const label = h('button.tree-label', {310 onclick: () => go(`folder/${folder.id}`),311 oncontextmenu: (e) => {312 e.preventDefault();313 nodeContextMenu(e, { id: folder.id, name: folder.name || 'My Drive', type: 'folder', color: folder.color, emoji: folder.emoji, starred: false, tags: [] });314 },315 },316 h('span', { html: folderIcon(folder), style: { display: 'contents' } }),317 folder.id === state.rootId ? 'My Drive' : folder.name);318 row.append(toggle, label);319 makeFolderDropTarget(row, folder.id);320 frag.append(row);321 if (expanded.has(folder.id) && kids.length) frag.append(build(folder.id, depth + 1));322 }323 return frag;324 };325 host.append(build(null, 0));326}327328function renderTagList() {329 const host = $('tagList');330 host.innerHTML = '';331 for (const tag of state.tags) {332 host.append(h('button.nav-item', { dataset: { nav: `tag/${tag.id}` }, onclick: () => go(`tag/${tag.id}`) },333 h('span', { html: `<svg viewBox="0 0 24 24" fill="${tag.color}" stroke="none" width="16" height="16"><circle cx="12" cy="12" r="6"/></svg>`, style: { display: 'contents' } }),334 tag.name,335 h('span.count', {}, String(tag.count ?? ''))));336 }337 host.append(h('button.nav-item', {338 onclick: () => editTagsDialog(),339 style: { color: 'var(--muted)' },340 }, h('span', { html: UI.plus, style: { display: 'contents' } }), 'Manage tags'));341}342343// Storage bucket palette — validated (CVD + contrast) in both themes; the344// color follows the bucket, never its rank. Values live in tokens.css.345const BUCKET_COLORS = {346 images: 'var(--cat-images)', video: 'var(--cat-video)', audio: 'var(--cat-audio)',347 docs: 'var(--cat-docs)', archives: 'var(--cat-archives)', other: 'var(--cat-other)',348};349350function renderStorageMeter(stats) {351 const host = $('storageMeter');352 const total = stats.usedBytes || 1;353 host.innerHTML = '';354 host.append(355 h('div', { style: { fontSize: '12px', fontWeight: 600 } }, `${fmtSize(stats.usedBytes)} used`),356 h('div.bar', {}, stats.byType.map((b) =>357 h('i', { style: { width: `${(b.bytes / total) * 100}%`, background: BUCKET_COLORS[b.bucket] ?? '#8b93a3' }, title: `${b.bucket}: ${fmtSize(b.bytes)}` }))),358 h('div.legend', {}, stats.byType.filter((b) => b.bytes > 0).map((b) =>359 h('span', {}, h('span.dot', { style: { background: BUCKET_COLORS[b.bucket] } }), `${b.bucket} ${fmtSize(b.bytes)}`))),360 h('div', { style: { fontSize: '11px', color: 'var(--muted)', marginTop: '5px' } }, `${stats.nodeCount} items · ${stats.blobCount} unique blobs`),361 );362}363364function highlightSidebar() {365 const key = state.route.view === 'folder' ? `folder/${state.route.id}` : `${state.route.view}${state.route.id !== undefined && state.route.view === 'tag' ? `/${state.route.id}` : ''}`;366 document.querySelectorAll('[data-nav]').forEach((el) => {367 el.classList.toggle('active', el.dataset.nav === key);368 });369 document.querySelectorAll('#bottomNav [data-bn]').forEach((b) => {370 b.classList.toggle('active',371 (b.dataset.bn === 'drive' && state.route.view === 'folder') || b.dataset.bn === state.route.view);372 });373}374375// ── Views: folder / recent / starred / tag / search / trash ──────────376async function loadFolder(id) {377 content.innerHTML = '<div class="grid">' + '<div class="skeleton"></div>'.repeat(8) + '</div>';378 let data;379 try {380 data = await getJSON(`/api/v1/nodes/${id}/children?sort=${state.sort}&dir=${state.dir}`);381 } catch {382 go(`folder/${state.rootId}`);383 return;384 }385 state.nodes = data.children;386 state.path = data.path;387 state.viewMode = state.viewModes[id] ?? state.viewMode;388 $('viewToggle').innerHTML = state.viewMode === 'grid' ? UI.listv : UI.grid;389 renderToolbar();390 renderNodes();391}392393async function loadRecent() {394 const { items } = await getJSON('/api/v1/recent');395 showFlatList('Recent', items, { empty: 'Nothing recent yet' });396}397398async function loadStarred() {399 const { items } = await getJSON('/api/v1/starred');400 showFlatList('Starred', items, { empty: 'Star files and folders to find them here fast' });401}402403async function loadTag(tagId) {404 const { items } = await getJSON(`/api/v1/tags/${tagId}/nodes`);405 const tag = state.tags.find((t) => t.id === tagId);406 showFlatList(`Tag: ${tag?.name ?? ''}`, items, { empty: 'No items carry this tag' });407}408409function showFlatList(title, items, { empty }) {410 state.nodes = items;411 state.path = [];412 toolbar.innerHTML = '';413 toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, title)), selectionToolbar());414 content.innerHTML = '';415 if (!items.length) {416 content.append(emptyState('search', empty, ''));417 return;418 }419 renderNodes();420}421422async function loadSearch(q) {423 $('searchInput').value = q;424 const filters = state.route.q ?? new URLSearchParams();425 const params = new URLSearchParams({ q });426 for (const [k, v] of filters) params.set(k, v);427 const { results } = await getJSON(`/api/v1/search?${params}`);428 state.nodes = results;429 state.path = [];430 toolbar.innerHTML = '';431 const chips = h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } });432 const filterDefs = [433 ['type', ['image', 'video', 'audio', 'doc', 'archive', 'folder']],434 ];435 for (const [key, values] of filterDefs) {436 for (const value of values) {437 const active = filters.get(key) === value;438 chips.append(h(`button.chip${active ? '.active' : ''}`, {439 onclick: () => {440 const p = new URLSearchParams(filters);441 active ? p.delete(key) : p.set(key, value);442 location.hash = `#/search/${encodeURIComponent(q)}?${p}`;443 },444 }, value));445 }446 }447 for (const flag of ['starred', 'shared']) {448 const active = filters.get(flag) === 'true';449 chips.append(h(`button.chip${active ? '.active' : ''}`, {450 onclick: () => {451 const p = new URLSearchParams(filters);452 active ? p.delete(flag) : p.set(flag, 'true');453 location.hash = `#/search/${encodeURIComponent(q)}?${p}`;454 },455 }, flag));456 }457 toolbar.append(458 h('div.crumbs', {}, h('span.crumb.current', {}, `Search “${q}” — ${results.length} result${results.length === 1 ? '' : 's'}`)),459 selectionToolbar(),460 );461 content.innerHTML = '';462 content.append(chips, h('div', { style: { height: '12px' } }));463 if (!results.length) {464 content.append(emptyState('search', 'No results', 'Try other words, or check filters'));465 return;466 }467 renderNodes(true);468}469470async function loadTrash() {471 const { items } = await getJSON('/api/v1/trash');472 state.nodes = items;473 state.path = [];474 toolbar.innerHTML = '';475 toolbar.append(476 h('div.crumbs', {}, h('span.crumb.current', {}, 'Trash'), h('span.muted', { style: { fontSize: '12px', marginLeft: '10px' } }, 'Items are deleted forever after 30 days')),477 items.length ? h('button.btn.danger', {478 onclick: async () => {479 if (await confirmModal({ title: 'Empty trash', message: `Permanently delete ${items.length} item(s)? This cannot be undone.`, confirmLabel: 'Delete forever', danger: true, typed: 'DELETE' })) {480 await post('/api/v1/trash/empty', {});481 toast('Trash emptied');482 loadTrash();483 refreshSidebar();484 }485 },486 }, 'Empty trash') : null,487 );488 content.innerHTML = '';489 if (!items.length) {490 content.append(emptyState('trash', 'Trash is empty', 'Deleted items land here for 30 days'));491 return;492 }493 renderNodes();494}495496// ── Toolbar (breadcrumbs + sort + selection ops) ─────────────────────497function renderToolbar() {498 toolbar.innerHTML = '';499 const crumbs = h('div.crumbs');500 state.path.forEach((part, i) => {501 if (i > 0) crumbs.append(h('span.crumb-sep', {}, '›'));502 const isLast = i === state.path.length - 1;503 const crumb = h(`button.crumb${isLast ? '.current' : ''}`, {504 onclick: () => go(`folder/${part.id}`),505 }, part.name || 'My Drive');506 makeFolderDropTarget(crumb, part.id);507 crumbs.append(crumb);508 });509 const sortSel = h('select', {510 style: { width: 'auto' },511 onchange: (e) => {512 const [sort, dir] = e.target.value.split(':');513 state.sort = sort; state.dir = dir;514 loadFolder(currentFolderId());515 },516 }, ...[['name:asc', 'Name ↑'], ['name:desc', 'Name ↓'], ['modified:desc', 'Newest'], ['modified:asc', 'Oldest'], ['size:desc', 'Largest'], ['size:asc', 'Smallest'], ['type:asc', 'Type']]517 .map(([v, l]) => h('option', { value: v, selected: `${state.sort}:${state.dir}` === v }, l)));518519 const slider = state.viewMode === 'grid'520 ? h('input', {521 type: 'range', min: 120, max: 260, step: 35, value: state.cardSize,522 title: 'Thumbnail size', style: { width: '90px' },523 oninput: (e) => {524 state.cardSize = Number(e.target.value);525 localStorage.setItem('spbdrive-card', e.target.value);526 content.querySelector('.grid')?.style.setProperty('--card', `${state.cardSize}px`);527 },528 })529 : null;530531 toolbar.append(crumbs, selectionToolbar(), sortSel, slider ?? '');532}533534function selectionToolbar() {535 const host = h('div.sel-toolbar');536 updateSelectionToolbar(host);537 return host;538}539540function updateSelectionToolbar(host) {541 host = host ?? toolbar.querySelector('.sel-toolbar');542 if (!host) return;543 host.innerHTML = '';544 const n = state.selection.size;545 if (!n) return;546 const inTrash = state.route.view === 'trash';547 host.append(h('span.sel-count', {}, `${n} selected`));548 const ids = [...state.selection];549 if (inTrash) {550 host.append(551 h('button.btn', { onclick: () => bulkRestore(ids) }, 'Restore'),552 h('button.btn.danger', { onclick: () => bulkDeleteForever(ids) }, 'Delete forever'),553 );554 } else {555 host.append(556 h('button.btn.icon', { title: 'Download', html: UI.download, onclick: () => downloadIds(ids) }),557 h('button.btn.icon', { title: 'Move', html: UI.move, onclick: () => moveDialog(ids) }),558 h('button.btn.icon', { title: 'Trash (Del)', html: UI.trash, onclick: () => bulkTrash(ids) }),559 );560 }561}562563// ── Node rendering (grid + list) ─────────────────────────────────────564function nodeById(id) { return state.nodes.find((n) => n.id === id); }565566function renderNodes(withSnippets = false) {567 const existingChips = state.route.view === 'search' ? [...content.children].slice(0, 2) : [];568 content.innerHTML = '';569 existingChips.forEach((c) => content.append(c));570571 if (!state.nodes.length && state.route.view === 'folder') {572 content.append(emptyState('folder', 'This folder is empty', 'Drop files anywhere, or press Upload'));573 return;574 }575576 const host = state.viewMode === 'grid' ? renderGrid() : renderList(withSnippets);577 content.append(host);578 wireRectangleSelect(host);579 updateSelectionToolbar();580}581582function thumbEl(node) {583 if (node.hasThumb) {584 const img = h('img', { loading: 'lazy', src: `/thumb/${node.id}?size=256`, alt: '' });585 img.onerror = () => img.replaceWith(h('span', { html: nodeIcon(node), style: { display: 'contents' } }));586 return img;587 }588 return h('span', { html: nodeIcon(node), style: { display: 'contents' } });589}590591function renderGrid() {592 const grid = h('div.grid', { style: { '--card': `${state.cardSize}px` } });593 for (const node of state.nodes) {594 const card = h('div.card', { dataset: { id: node.id }, tabindex: 0 },595 h('div.thumb', {}, thumbEl(node)),596 h('div.meta', {},597 h('span', { html: nodeIcon(node), style: { display: 'contents' } }),598 h('span.name', { title: node.name }, node.name),599 ),600 );601 if (node.starred) card.append(h('span.star-ind', { html: UI.star }));602 if (node.tags?.length) card.append(h('span.tag-strip', { style: { background: node.tags[0].color } }));603 wireNode(card, node);604 grid.append(card);605 }606 return grid;607}608609function renderList(withSnippets) {610 const list = h('div.list');611 const headBtn = (label, key) => h('button', {612 onclick: () => {613 state.dir = state.sort === key && state.dir === 'asc' ? 'desc' : 'asc';614 state.sort = key;615 state.route.view === 'folder' ? loadFolder(currentFolderId()) : sortLocal();616 },617 }, label, state.sort === key ? (state.dir === 'asc' ? ' ↑' : ' ↓') : '');618 list.append(h('div.list-header', {},619 h('span'), headBtn('Name', 'name'), headBtn('Size', 'size'), headBtn('Type', 'type'), headBtn('Modified', 'modified'), h('span', {}, 'Tags')));620 for (const node of state.nodes) {621 const row = h('div.row', { dataset: { id: node.id }, tabindex: 0 },622 h('span.star-cell', { html: node.starred ? UI.star : '' }),623 h('div.name', {},624 h('span', { html: nodeIcon(node), style: { display: 'contents' } }),625 h('span', { title: node.name }, node.name),626 ),627 h('span.cell', {}, node.type === 'folder' ? '—' : fmtSize(node.size)),628 h('span.cell', {}, node.type === 'folder' ? 'Folder' : (node.mime?.split('/')[1] ?? node.strategy ?? 'file')),629 h('span.cell', {}, fmtDate(node.modified)),630 h('span.rowtags', {}, (node.tags ?? []).slice(0, 3).map((t) =>631 h('span.chip', { style: { borderColor: t.color, color: t.color } }, t.name))),632 );633 if (withSnippets && node.snippet) {634 row.append(h('div', { style: { gridColumn: '2 / -1', fontSize: '12px', color: 'var(--muted)' }, html: node.snippet }));635 }636 wireNode(row, node);637 list.append(row);638 }639 return list;640}641642function sortLocal() {643 const dir = state.dir === 'asc' ? 1 : -1;644 const key = state.sort;645 state.nodes.sort((a, b) => {646 if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;647 if (key === 'size' || key === 'modified') return (a[key] - b[key]) * dir;648 return String(a[key === 'type' ? 'mime' : key] ?? '').localeCompare(String(b[key === 'type' ? 'mime' : key] ?? '')) * dir;649 });650 renderNodes();651}652653function emptyState(art, title, subtitle) {654 return h('div.empty', {}, h('div', {},655 h('div', { html: EMPTY_ART[art] ?? EMPTY_ART.folder }),656 h('h3', {}, title),657 h('p', {}, subtitle)));658}659660// ── Node interactions: select, open, dnd, context menu ───────────────661function wireNode(el, node) {662 el.addEventListener('click', (e) => {663 e.stopPropagation();664 // Touch UX: a tap opens directly (long-press = menu / select).665 if (IS_TOUCH && !e.shiftKey && !e.metaKey && !e.ctrlKey) {666 state.selection = new Set([node.id]);667 state.anchor = node.id;668 paintSelection();669 openNode(node);670 return;671 }672 if (e.shiftKey && state.anchor !== null) {673 const ids = state.nodes.map((n) => n.id);674 const a = ids.indexOf(state.anchor);675 const b = ids.indexOf(node.id);676 state.selection = new Set(ids.slice(Math.min(a, b), Math.max(a, b) + 1));677 } else if (e.metaKey || e.ctrlKey) {678 state.selection.has(node.id) ? state.selection.delete(node.id) : state.selection.add(node.id);679 state.anchor = node.id;680 } else {681 state.selection = new Set([node.id]);682 state.anchor = node.id;683 }684 paintSelection();685 showInfo(node);686 });687688 el.addEventListener('dblclick', () => openNode(node));689 el.addEventListener('keydown', (e) => {690 if (e.key === 'Enter') openNode(node);691 });692693 el.addEventListener('contextmenu', (e) => {694 e.preventDefault();695 if (!state.selection.has(node.id)) {696 state.selection = new Set([node.id]);697 state.anchor = node.id;698 paintSelection();699 }700 nodeContextMenu(e, node);701 });702703 // Touch: long-press opens the context menu (mobile has no right-click).704 let pressTimer = null;705 let pressFired = false;706 el.addEventListener('touchstart', (e) => {707 pressFired = false;708 const t = e.touches[0];709 pressTimer = setTimeout(() => {710 pressFired = true;711 navigator.vibrate?.(12);712 state.selection = new Set([node.id]);713 state.anchor = node.id;714 paintSelection();715 nodeContextMenu({ preventDefault() {}, clientX: t.clientX, clientY: t.clientY }, node);716 }, 460);717 }, { passive: true });718 for (const ev of ['touchend', 'touchmove', 'touchcancel']) {719 el.addEventListener(ev, (e) => {720 clearTimeout(pressTimer);721 // Swallow the tap that follows a long-press so it doesn't open the node.722 if (pressFired && ev === 'touchend') e.preventDefault();723 }, { passive: false });724 }725726 // Drag to move727 el.draggable = state.route.view !== 'trash';728 el.addEventListener('dragstart', (e) => {729 if (!state.selection.has(node.id)) {730 state.selection = new Set([node.id]);731 paintSelection();732 }733 e.dataTransfer.setData('application/x-spbdrive-ids', JSON.stringify([...state.selection]));734 e.dataTransfer.effectAllowed = 'move';735 });736 if (node.type === 'folder') makeFolderDropTarget(el, node.id);737}738739function makeFolderDropTarget(el, folderId) {740 el.addEventListener('dragover', (e) => {741 if (![...e.dataTransfer.types].includes('application/x-spbdrive-ids')) return;742 e.preventDefault();743 e.dataTransfer.dropEffect = 'move';744 el.classList.add('drop-target');745 });746 el.addEventListener('dragleave', () => el.classList.remove('drop-target'));747 el.addEventListener('drop', async (e) => {748 el.classList.remove('drop-target');749 const raw = e.dataTransfer.getData('application/x-spbdrive-ids');750 if (!raw) return;751 e.preventDefault();752 e.stopPropagation();753 const ids = JSON.parse(raw).filter((id) => id !== folderId);754 if (ids.length) await moveIds(ids, folderId);755 });756}757758function paintSelection() {759 content.querySelectorAll('[data-id]').forEach((el) => {760 el.classList.toggle('selected', state.selection.has(Number(el.dataset.id)));761 });762 updateSelectionToolbar();763}764765function openNode(node) {766 if (node.type === 'folder') {767 if (state.route.view === 'trash') return;768 go(`folder/${node.id}`);769 return;770 }771 openViewer(node);772}773774function openViewer(node) {775 const files = state.nodes.filter((n) => n.type === 'file');776 const index = files.findIndex((n) => n.id === node.id);777 const viewer = new Viewer(files, Math.max(index, 0), {778 descUrl: (n) => `/api/v1/preview/${n.id}`,779 streamUrl: (n) => `/stream/${n.id}`,780 dlUrl: (n) => `/dl/${n.id}`,781 actions: [782 { title: 'Download', icon: UI.download, onClick: (n) => { location.href = `/dl/${n.id}`; } },783 {784 title: 'Edit', icon: UI.edit, visible: (n) => isEditable(n),785 onClick: (n, v) => { v.close(); openEditor(n); },786 },787 { title: 'Share', icon: UI.share, onClick: (n) => shareDialog(n) },788 {789 title: 'Star', icon: UI.starO,790 onClick: async (n) => { await toggleStar(n); },791 },792 { title: 'Info', icon: UI.info, onClick: (n, v) => { v.close(); showInfo(n, true); } },793 {794 title: 'Delete', icon: UI.trash,795 onClick: async (n, v) => { v.close(); await bulkTrash([n.id]); },796 },797 ],798 });799 return viewer;800}801802function openEditor(node) {803 return new Editor(node, {804 onSaved: () => { refreshCurrent(); },805 });806}807808// ── Context menu ─────────────────────────────────────────────────────809function nodeContextMenu(e, node) {810 const multi = state.selection.size > 1;811 const ids = multi ? [...state.selection] : [node.id];812 const inTrash = state.route.view === 'trash';813814 if (inTrash) {815 contextMenu(e.clientX, e.clientY, [816 { label: multi ? `Restore ${ids.length} items` : 'Restore', icon: UI.restore, onClick: () => bulkRestore(ids) },817 { sep: true },818 { label: 'Delete forever', icon: UI.trash, danger: true, onClick: () => bulkDeleteForever(ids) },819 ]);820 return;821 }822823 const colorRow = node.type === 'folder' && !multi824 ? h('div.ctx-colors', {}, FOLDER_COLORS.map((c) =>825 h('button', {826 style: { background: `var(--folder-${c})` }, title: c,827 onclick: async () => {828 const { closeContextMenu } = await import('./ui.js');829 closeContextMenu();830 await patch(`/api/v1/nodes/${node.id}`, { color: c });831 refreshCurrent(); refreshSidebar();832 },833 })))834 : null;835836 contextMenu(e.clientX, e.clientY, [837 !multi && { label: node.type === 'folder' ? 'Open' : 'Preview', icon: UI.eye, kbd: '↵', onClick: () => openNode(node) },838 !multi && isEditable(node) && { label: 'Edit', icon: UI.edit, kbd: 'E', onClick: () => openEditor(node) },839 !multi && node.type === 'file' && { label: 'Download', icon: UI.download, onClick: () => { location.href = `/dl/${node.id}`; } },840 multi && { label: `Download ${ids.length} as ZIP`, icon: UI.download, onClick: () => downloadIds(ids) },841 { label: 'Share', icon: UI.share, onClick: () => shareDialog(node) },842 { sep: true },843 !multi && { label: 'Rename', icon: UI.rename, kbd: 'F2', onClick: () => inlineRename(node) },844 { label: 'Move to…', icon: UI.move, onClick: () => moveDialog(ids) },845 !multi && { label: 'Duplicate', icon: UI.duplicate, onClick: () => duplicateNode(node) },846 !multi && node.type === 'file' && { label: 'Version history', icon: UI.history, onClick: () => versionsDialog(node) },847 !multi && node.type === 'folder' && { label: 'Request files here…', icon: UI.inbox, onClick: () => newRequestDialog(node.id) },848 { label: node.starred && !multi ? 'Unstar' : 'Star', icon: UI.starO, kbd: 'S', onClick: () => Promise.all(ids.map((id) => toggleStar(nodeById(id) ?? node))).then(refreshCurrent) },849 { label: 'Tags…', icon: UI.tag, onClick: () => tagDialog(node) },850 !multi && { label: 'Details', icon: UI.info, onClick: () => showInfo(node, true) },851 colorRow && { sep: true },852 colorRow && { custom: colorRow },853 node.type === 'folder' && !multi && { custom: emojiRow(node) },854 { sep: true },855 { label: multi ? `Move ${ids.length} to trash` : 'Move to trash', icon: UI.trash, danger: true, kbd: 'Del', onClick: () => bulkTrash(ids) },856 ].filter(Boolean));857}858859function emojiRow(node) {860 const emojis = ['📁', '📸', '🎬', '🎵', '💼', '🧠', '🚀', '❤️', ''];861 return h('div.ctx-colors', {}, emojis.map((em) =>862 h('button', {863 style: { background: 'var(--surface-2)', fontSize: '12px' }, title: em || 'none',864 onclick: async () => {865 const { closeContextMenu } = await import('./ui.js');866 closeContextMenu();867 await patch(`/api/v1/nodes/${node.id}`, { emoji: em || null });868 refreshCurrent(); refreshSidebar();869 },870 }, em || '∅')));871}872873// ── Operations ───────────────────────────────────────────────────────874function refreshCurrent() { onRoute(); }875876async function newFolderDialog() {877 const input = h('input', { type: 'text', placeholder: 'Folder name' });878 modal({879 title: 'New folder',880 body: h('div', {}, input),881 actions: [882 { label: 'Cancel', onClick: () => {} },883 {884 label: 'Create', primary: true,885 onClick: async () => {886 const name = input.value.trim();887 if (!name) return false;888 await post('/api/v1/nodes', { parentId: currentFolderId(), name, type: 'folder' });889 refreshCurrent(); refreshSidebar();890 return true;891 },892 },893 ],894 });895}896897async function newTextFileDialog() {898 const stamp = new Date().toISOString().slice(0, 10);899 const input = h('input', { type: 'text', value: `notes-${stamp}.md` });900 modal({901 title: 'New text file',902 body: h('div', {}, input),903 actions: [904 { label: 'Cancel', onClick: () => {} },905 {906 label: 'Create & edit', primary: true,907 onClick: async () => {908 const name = input.value.trim();909 if (!name) return false;910 const { node } = await post('/api/v1/files', { parentId: currentFolderId(), name, content: '' });911 refreshCurrent();912 openEditor(node);913 return true;914 },915 },916 ],917 });918 const dot = input.value.lastIndexOf('.');919 input.setSelectionRange(0, dot > 0 ? dot : input.value.length);920}921922async function toggleStar(node) {923 const next = !node.starred;924 node.starred = next; // optimistic925 paintSelection();926 try {927 await patch(`/api/v1/nodes/${node.id}`, { starred: next });928 } catch {929 node.starred = !next;930 toast('Could not update star', { error: true });931 }932 refreshCurrent();933}934935function inlineRename(node) {936 const el = content.querySelector(`[data-id="${node.id}"]`);937 const nameEl = el?.querySelector('.name span:last-child, .meta .name');938 if (!nameEl) return;939 const input = h('input.rename-input', { type: 'text', value: node.name });940 nameEl.replaceWith(input);941 input.focus();942 const dot = node.name.lastIndexOf('.');943 input.setSelectionRange(0, node.type === 'file' && dot > 0 ? dot : node.name.length);944 const done = async (commit) => {945 input.onblur = null;946 const name = input.value.trim();947 if (commit && name && name !== node.name) {948 try {949 await patch(`/api/v1/nodes/${node.id}`, { name });950 node.name = name;951 } catch (err) {952 toast(err.message, { error: true });953 }954 }955 refreshCurrent();956 if (node.type === 'folder') refreshSidebar();957 };958 input.onblur = () => done(true);959 input.onkeydown = (e) => {960 e.stopPropagation();961 if (e.key === 'Enter') done(true);962 if (e.key === 'Escape') done(false);963 };964 input.onclick = (e) => e.stopPropagation();965}966967async function duplicateNode(node) {968 await post(`/api/v1/nodes/${node.id}/duplicate`, {});969 toast('Duplicated');970 refreshCurrent();971}972973async function bulkTrash(ids) {974 for (const id of ids) await del(`/api/v1/nodes/${id}`);975 state.selection.clear();976 toast(`Moved ${ids.length > 1 ? `${ids.length} items` : 'item'} to trash`, {977 actionLabel: 'Undo',978 onAction: async () => {979 for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});980 refreshCurrent(); refreshSidebar();981 },982 });983 refreshCurrent(); refreshSidebar();984}985986async function bulkRestore(ids) {987 for (const id of ids) await post(`/api/v1/nodes/${id}/restore`, {});988 state.selection.clear();989 toast('Restored');990 refreshCurrent(); refreshSidebar();991}992993async function bulkDeleteForever(ids) {994 const ok = await confirmModal({995 title: 'Delete forever',996 message: `Permanently delete ${ids.length} item(s)? This cannot be undone.`,997 confirmLabel: 'Delete forever',998 danger: true,999 });1000 if (!ok) return;1001 for (const id of ids) await del(`/api/v1/nodes/${id}?force=true`);1002 state.selection.clear();1003 toast('Deleted forever');1004 refreshCurrent(); refreshSidebar();1005}10061007function downloadIds(ids) {1008 if (ids.length === 1) {1009 const node = nodeById(ids[0]);1010 if (node?.type === 'file') { location.href = `/dl/${node.id}`; return; }1011 }1012 location.href = `/api/v1/zip?ids=${ids.join(',')}`;1013}10141015async function moveIds(ids, targetId) {1016 let skipped = 0;1017 for (const id of ids) {1018 try {1019 await patch(`/api/v1/nodes/${id}`, { parentId: targetId });1020 } catch (err) {1021 skipped += 1;1022 if (err.status === 409) {1023 const choice = await conflictDialog(nodeById(id)?.name ?? 'item');1024 if (choice && choice !== 'skip') {1025 await patch(`/api/v1/nodes/${id}`, { parentId: targetId, conflict: choice });1026 skipped -= 1;1027 }1028 } else toast(err.message, { error: true });1029 }1030 }1031 state.selection.clear();1032 toast(`Moved ${ids.length - skipped} item(s)`);1033 refreshCurrent(); refreshSidebar();1034}10351036function conflictDialog(name) {1037 return new Promise((resolve) => {1038 modal({1039 title: 'Name conflict',1040 body: h('p', { style: { color: 'var(--muted)' } }, `"${name}" already exists in the destination.`),1041 onClose: () => resolve(null),1042 actions: [1043 { label: 'Skip', onClick: () => resolve('skip') },1044 { label: 'Replace', danger: true, onClick: () => resolve('replace') },1045 { label: 'Keep both', primary: true, onClick: () => resolve('keep-both') },1046 ],1047 });1048 });1049}10501051function moveDialog(ids) {1052 const tree = h('div.picker-tree');1053 let chosen = state.rootId;1054 const byParent = new Map();1055 for (const f of state.tree) {1056 if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);1057 byParent.get(f.parent_id).push(f);1058 }1059 const build = (parentId, depth) => {1060 for (const folder of byParent.get(parentId) ?? []) {1061 if (ids.includes(folder.id)) continue; // can't move into itself1062 const row = h('button.nav-item', {1063 style: { paddingLeft: `${12 + depth * 16}px` },1064 onclick: (e) => {1065 tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));1066 e.currentTarget.classList.add('active');1067 chosen = folder.id;1068 },1069 },1070 h('span', { html: folderIcon(folder), style: { display: 'contents' } }),1071 folder.id === state.rootId ? 'My Drive' : folder.name);1072 tree.append(row);1073 build(folder.id, depth + 1);1074 }1075 };1076 build(null, 0);1077 modal({1078 title: `Move ${ids.length} item(s)`,1079 body: tree,1080 actions: [1081 { label: 'Cancel', onClick: () => {} },1082 { label: 'Copy here', onClick: async () => { for (const id of ids) await post(`/api/v1/nodes/${id}/copy`, { parentId: chosen }); toast('Copied'); refreshCurrent(); refreshSidebar(); } },1083 { label: 'Move here', primary: true, onClick: () => moveIds(ids, chosen) },1084 ],1085 });1086}10871088// ── Tags ─────────────────────────────────────────────────────────────1089async function tagDialog(node) {1090 const { tags } = await getJSON('/api/v1/tags');1091 const current = new Set((node.tags ?? []).map((t) => t.id));1092 const list = h('div.tag-editor', {}, tags.map((tag) =>1093 h(`button.chip${current.has(tag.id) ? '.active' : ''}`, {1094 onclick: (e) => {1095 current.has(tag.id) ? current.delete(tag.id) : current.add(tag.id);1096 e.currentTarget.classList.toggle('active');1097 },1098 }, h('span.dot', { style: { background: tag.color } }), tag.name)));1099 const newInput = h('input', { type: 'text', placeholder: 'New tag name…', style: { marginTop: '12px' } });1100 newInput.addEventListener('keydown', async (e) => {1101 if (e.key !== 'Enter' || !newInput.value.trim()) return;1102 const { tag } = await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });1103 current.add(tag.id);1104 list.append(h('button.chip.active', {}, h('span.dot', { style: { background: tag.color } }), tag.name));1105 newInput.value = '';1106 });1107 modal({1108 title: `Tags — ${node.name}`,1109 body: h('div', {}, list, newInput),1110 actions: [1111 { label: 'Cancel', onClick: () => {} },1112 {1113 label: 'Save', primary: true,1114 onClick: async () => {1115 await patch(`/api/v1/nodes/${node.id}`, { tagIds: [...current] });1116 refreshCurrent(); refreshSidebar();1117 },1118 },1119 ],1120 });1121}11221123function randomTagColor() {1124 const palette = ['#4f8cff', '#22d3aa', '#7bd88f', '#ffd166', '#ff9f5a', '#ff5d5d', '#b48cff', '#ff7ab8'];1125 return palette[Math.floor(Math.random() * palette.length)];1126}11271128async function editTagsDialog() {1129 const { tags } = await getJSON('/api/v1/tags');1130 const list = h('div');1131 for (const tag of tags) {1132 const nameInput = h('input', { type: 'text', value: tag.name, style: { width: '160px' } });1133 const colorInput = h('input', { type: 'color', value: tag.color, style: { width: '42px', padding: '2px' } });1134 list.append(h('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px', alignItems: 'center' } },1135 colorInput, nameInput,1136 h('button.btn.icon.ghost', {1137 title: 'Save', html: UI.check,1138 onclick: async () => { await patch(`/api/v1/tags/${tag.id}`, { name: nameInput.value, color: colorInput.value }); toast('Tag saved'); refreshSidebar(); },1139 }),1140 h('button.btn.icon.ghost.danger', {1141 title: 'Delete', html: UI.trash,1142 onclick: async () => { await del(`/api/v1/tags/${tag.id}`); toast('Tag deleted'); refreshSidebar(); list.querySelector(`[data-tag="${tag.id}"]`)?.remove(); },1143 })));1144 }1145 const newInput = h('input', { type: 'text', placeholder: 'New tag — press Enter' });1146 newInput.addEventListener('keydown', async (e) => {1147 if (e.key === 'Enter' && newInput.value.trim()) {1148 await post('/api/v1/tags', { name: newInput.value.trim(), color: randomTagColor() });1149 refreshSidebar();1150 toast('Tag created');1151 newInput.value = '';1152 }1153 });1154 modal({1155 title: 'Manage tags',1156 body: h('div', {}, list, newInput),1157 actions: [{ label: 'Done', primary: true, onClick: () => {} }],1158 });1159}11601161// ── Info panel ───────────────────────────────────────────────────────1162function showInfo(node, force = false) {1163 state.infoNode = node;1164 if (force) $('body').classList.add('info-open');1165 updateInfoPanel();1166}11671168async function updateInfoPanel() {1169 const panel = $('infoPanel');1170 const node = state.infoNode;1171 if (!node) {1172 panel.innerHTML = '';1173 $('body').classList.remove('info-open');1174 return;1175 }1176 if (!$('body').classList.contains('info-open')) return;1177 panel.innerHTML = '';1178 panel.append(1179 h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },1180 h('button.btn.icon.ghost', { html: UI.close, onclick: () => { state.infoNode = null; updateInfoPanel(); } })),1181 h('div.info-thumb', {}, node.type === 'file' && node.hasThumb1182 ? h('img', { src: `/thumb/${node.id}?size=512` })1183 : h('span', { html: nodeIcon(node), style: { display: 'contents' } })),1184 h('h3', {}, node.name),1185 );1186 const kv = h('dl.kv');1187 const add = (k, v) => kv.append(h('dt', {}, k), h('dd', {}, v));1188 add('Type', node.type === 'folder' ? 'Folder' : (node.mime ?? 'file'));1189 if (node.type === 'file') add('Size', fmtSize(node.size));1190 add('Created', fmtDate(node.created));1191 add('Modified', fmtDate(node.modified));1192 panel.append(kv);11931194 // Tags section1195 const tagWrap = h('div.tag-editor', {}, (node.tags ?? []).map((t) =>1196 h('span.chip.active', { style: { borderColor: t.color, color: t.color } }, t.name)));1197 tagWrap.append(h('button.chip', { onclick: () => tagDialog(node) }, '+ edit'));1198 panel.append(h('div.info-section', {}, h('h4', {}, 'Tags'), tagWrap));11991200 // Shares section1201 try {1202 const { shares } = await getJSON(`/api/v1/nodes/${node.id}/shares`);1203 const wrap = h('div');1204 for (const share of shares) {1205 wrap.append(h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },1206 h('span.mono', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, `/s/${share.token}`),1207 h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy URL', onclick: () => copyText(share.url, 'Share URL copied') }),1208 h('button.btn.icon.ghost.danger', { html: UI.close, title: 'Revoke', onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Share revoked'); updateInfoPanel(); } })));1209 }1210 wrap.append(h('button.btn', { style: { marginTop: '4px' }, onclick: () => shareDialog(node) },1211 h('span', { html: UI.share, style: { display: 'contents' } }), 'New share link'));1212 panel.append(h('div.info-section', {}, h('h4', {}, `Shares (${shares.length})`), wrap));1213 } catch {}12141215 // Version history section (files only)1216 if (node.type === 'file') {1217 try {1218 const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);1219 const wrap = h('div');1220 if (!versions.length) {1221 wrap.append(h('p.muted', { style: { fontSize: '12px', margin: 0 } },1222 'No previous versions yet — replacing or editing this file keeps its history here.'));1223 }1224 for (const v of versions.slice(0, 5)) wrap.append(versionRow(node, v, () => updateInfoPanel()));1225 if (versions.length > 5) {1226 wrap.append(h('button.btn', { style: { marginTop: '6px' }, onclick: () => versionsDialog(node) },1227 `All ${versions.length} versions…`));1228 }1229 panel.append(h('div.info-section', {}, h('h4', {}, `Versions (${versions.length})`), wrap));1230 } catch {}1231 }1232}12331234// ── Version history ──────────────────────────────────────────────────1235function versionRow(node, v, onChange) {1236 return h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },1237 h('div', { style: { flex: 1, minWidth: 0 } },1238 h('div', {}, `${fmtDate(v.replacedAt)} · ${fmtSize(v.size)}`),1239 h('div', { style: { color: 'var(--muted)', fontSize: '11px' } }, ({1240 replace: 'replaced by upload', edit: 'edited in browser', restore: 'superseded by restore',1241 })[v.origin] ?? v.origin)),1242 h('a.btn.icon.ghost', { html: UI.download, title: 'Download this version', href: `/api/v1/nodes/${node.id}/versions/${v.id}/dl` }),1243 h('button.btn.icon.ghost', {1244 html: UI.restore, title: 'Restore this version',1245 onclick: async () => {1246 await post(`/api/v1/nodes/${node.id}/versions/${v.id}/restore`, {});1247 toast('Version restored — current content kept in history');1248 refreshCurrent();1249 onChange?.();1250 },1251 }),1252 h('button.btn.icon.ghost.danger', {1253 html: UI.close, title: 'Delete this version',1254 onclick: async () => {1255 await del(`/api/v1/nodes/${node.id}/versions/${v.id}`);1256 toast('Version deleted');1257 onChange?.();1258 },1259 }));1260}12611262async function versionsDialog(node) {1263 const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);1264 const list = h('div', { style: { maxHeight: '55vh', overflow: 'auto' } });1265 const rerender = async () => {1266 const fresh = await getJSON(`/api/v1/nodes/${node.id}/versions`);1267 list.innerHTML = '';1268 if (!fresh.versions.length) list.append(h('p.muted', {}, 'No previous versions.'));1269 for (const v of fresh.versions) list.append(versionRow(node, v, rerender));1270 };1271 if (!versions.length) list.append(h('p.muted', {}, 'No previous versions.'));1272 for (const v of versions) list.append(versionRow(node, v, rerender));1273 modal({1274 title: `Version history — ${node.name}`,1275 body: list,1276 actions: [{ label: 'Close', primary: true, onClick: () => {} }],1277 });1278}12791280// ── Share dialog & manager ───────────────────────────────────────────1281function shareDialog(node) {1282 const expiry = h('select', {},1283 ...[['', 'Never'], ['3600000', '1 hour'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]1284 .map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));1285 const password = h('input', { type: 'text', placeholder: 'Optional password', autocomplete: 'off' });1286 const maxDl = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });1287 const allowDl = h('input', { type: 'checkbox', checked: true });1288 const label = h('input', { type: 'text', placeholder: 'Note to self (optional)' });1289 const result = h('div');12901291 modal({1292 title: `Share — ${node.name}`,1293 body: h('div', {},1294 h('label.field', {}, h('span', {}, 'Expires'), expiry),1295 h('label.field', {}, h('span', {}, 'Password'), password),1296 h('label.field', {}, h('span', {}, 'Max downloads'), maxDl),1297 h('label.field', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, allowDl, h('span', { style: { margin: 0 } }, 'Allow download (off = preview only)')),1298 h('label.field', {}, h('span', {}, 'Label'), label),1299 result),1300 actions: [1301 { label: 'Close', onClick: () => {} },1302 {1303 label: 'Create link', primary: true,1304 onClick: async () => {1305 const { share } = await post('/api/v1/shares', {1306 nodeId: node.id,1307 expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,1308 password: password.value || null,1309 maxDownloads: maxDl.value ? Number(maxDl.value) : null,1310 allowDownload: allowDl.checked,1311 label: label.value || null,1312 });1313 result.innerHTML = '';1314 result.append(1315 h('div.share-url', {},1316 h('input', { type: 'text', value: share.url, readonly: true, onclick: (e) => e.target.select() }),1317 h('button.btn.primary', { onclick: () => copyText(share.url, 'Share URL copied') }, 'Copy')),1318 h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 160, height: 160, alt: 'QR code' })),1319 );1320 updateInfoPanel();1321 return false; // keep modal open to show the URL1322 },1323 },1324 ],1325 });1326}13271328async function loadShares() {1329 const { shares } = await getJSON('/api/v1/shares');1330 state.nodes = [];1331 toolbar.innerHTML = '';1332 toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, `Shared links (${shares.filter((s) => !s.revokedAt).length} active)`)));1333 content.innerHTML = '';1334 if (!shares.length) {1335 content.append(emptyState('search', 'No share links yet', 'Right-click any file or folder → Share'));1336 return;1337 }1338 const table = h('table.shares-table', {},1339 h('thead', {}, h('tr', {},1340 ...['Item', 'Link', 'Visits', 'Downloads', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));1341 const tbody = h('tbody');1342 for (const share of shares) {1343 const expired = share.revokedAt || (share.expiresAt && share.expiresAt < Date.now());1344 const expiry = share.revokedAt ? 'revoked'1345 : !share.expiresAt ? 'never'1346 : share.expiresAt < Date.now() ? 'expired'1347 : countdown(share.expiresAt);1348 tbody.append(h('tr', { style: expired ? { opacity: 0.55 } : {} },1349 h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },1350 h('span', { html: share.nodeType === 'folder' ? folderIcon({}) : fileIcon('file'), style: { display: 'contents' } }),1351 h('span', {}, share.nodeName),1352 share.label ? h('span.chip', {}, share.label) : '')),1353 h('td', {}, h('div.url-cell', {},1354 h('span', {}, `/s/${share.token}`),1355 h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(share.url, 'URL copied') }),1356 h('button.btn.icon.ghost', { html: UI.qr, title: 'QR code', onclick: () => showQr(share) }))),1357 h('td', {}, String(share.visits)),1358 h('td', {}, `${share.downloads}${share.maxDownloads ? ` / ${share.maxDownloads}` : ''}`),1359 h('td', {}, expiry),1360 h('td', {}, h('span.chip', { style: expired ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },1361 expired ? 'inactive' : share.hasPassword ? '🔒 active' : 'active')),1362 h('td', {}, h('div', { style: { display: 'flex', gap: '4px' } },1363 h('button.btn.icon.ghost', { html: UI.activity, title: 'Visit log', onclick: () => showShareEvents(share) }),1364 !share.revokedAt ? h('button.btn.icon.ghost.danger', {1365 html: UI.close, title: 'Revoke',1366 onclick: async () => { await del(`/api/v1/shares/${share.id}`); toast('Revoked'); loadShares(); },1367 }) : '')),1368 ));1369 }1370 table.append(tbody);1371 content.append(table);1372}13731374function countdown(ts) {1375 const diff = ts - Date.now();1376 if (diff < 3_600_000) return `${Math.max(1, Math.round(diff / 60_000))} min`;1377 if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} h`;1378 return `${Math.round(diff / 86_400_000)} d`;1379}13801381function showQr(share) {1382 modal({1383 title: 'QR code',1384 body: h('div.share-qr', {}, h('img', { src: `/api/v1/shares/${share.id}/qr`, width: 220, height: 220, alt: 'QR code' }),1385 h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, share.url)),1386 actions: [{ label: 'Close', primary: true, onClick: () => {} }],1387 });1388}13891390async function showShareEvents(share) {1391 const { events } = await getJSON(`/api/v1/shares/${share.id}/events`);1392 const list = h('div', { style: { maxHeight: '50vh', overflow: 'auto' } });1393 if (!events.length) list.append(h('p.muted', {}, 'No visits yet.'));1394 for (const ev of events) {1395 list.append(h('div.act-row', {},1396 h('span', { html: ev.kind === 'download' ? UI.download : UI.eye, style: { display: 'contents' } }),1397 h('div', {}, h('div', {}, `${ev.kind} · ${ev.ip || 'unknown ip'}`), h('div.ua', { style: { color: 'var(--muted)', fontSize: '11px' } }, ev.ua ?? '')),1398 h('span.when', {}, fmtDate(ev.ts))));1399 }1400 modal({ title: `Visits — /s/${share.token}`, body: list, wide: true, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });1401}14021403// ── File requests (receive files) ────────────────────────────────────1404function folderPickerTree(onPick, preselect) {1405 const tree = h('div.picker-tree');1406 const byParent = new Map();1407 for (const f of state.tree) {1408 if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);1409 byParent.get(f.parent_id).push(f);1410 }1411 const build = (parentId, depth) => {1412 for (const folder of byParent.get(parentId) ?? []) {1413 const row = h(`button.nav-item${folder.id === preselect ? '.active' : ''}`, {1414 style: { paddingLeft: `${12 + depth * 16}px` },1415 onclick: (e) => {1416 tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));1417 e.currentTarget.classList.add('active');1418 onPick(folder.id);1419 },1420 },1421 h('span', { html: folderIcon(folder), style: { display: 'contents' } }),1422 folder.id === state.rootId ? 'My Drive' : folder.name);1423 tree.append(row);1424 build(folder.id, depth + 1);1425 }1426 };1427 build(null, 0);1428 return tree;1429}14301431function newRequestDialog(folderId = state.rootId) {1432 let chosen = folderId;1433 const tree = folderPickerTree((id) => { chosen = id; }, folderId);1434 const label = h('input', { type: 'text', placeholder: 'What are you asking for? (shown to the sender)' });1435 const expiry = h('select', {},1436 ...[['', 'Never'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]1437 .map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));1438 const maxFiles = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });1439 const result = h('div');1440 modal({1441 title: 'Request files from someone',1442 body: h('div', {},1443 h('p', { style: { color: 'var(--muted)', fontSize: '12.5px', margin: '0 0 12px' } },1444 'Anyone with the link can upload into the chosen folder — they never see its contents.'),1445 h('label.field', {}, h('span', {}, 'Destination folder'), tree),1446 h('label.field', {}, h('span', {}, 'Note to sender'), label),1447 h('label.field', {}, h('span', {}, 'Expires'), expiry),1448 h('label.field', {}, h('span', {}, 'Max files'), maxFiles),1449 result),1450 actions: [1451 { label: 'Close', onClick: () => {} },1452 {1453 label: 'Create link', primary: true,1454 onClick: async () => {1455 const { request } = await post('/api/v1/requests', {1456 folderId: chosen,1457 label: label.value || null,1458 expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,1459 maxFiles: maxFiles.value ? Number(maxFiles.value) : null,1460 });1461 result.innerHTML = '';1462 result.append(1463 h('div.share-url', {},1464 h('input', { type: 'text', value: request.url, readonly: true, onclick: (e) => e.target.select() }),1465 h('button.btn.primary', { onclick: () => copyText(request.url, 'Request URL copied') }, 'Copy')),1466 h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${request.id}/qr`, width: 160, height: 160, alt: 'QR code' })),1467 );1468 if (state.route.view === 'requests') loadRequests();1469 return false; // keep open to show the URL1470 },1471 },1472 ],1473 });1474}14751476async function loadRequests() {1477 const { requests } = await getJSON('/api/v1/requests');1478 state.nodes = [];1479 toolbar.innerHTML = '';1480 toolbar.append(1481 h('div.crumbs', {}, h('span.crumb.current', {}, `File requests (${requests.filter((r) => !r.closedAt).length} active)`)),1482 h('button.btn.primary', { onclick: () => newRequestDialog(state.rootId) }, '+ New request'),1483 );1484 content.innerHTML = '';1485 if (!requests.length) {1486 content.append(emptyState('search', 'No file requests yet', 'Create a link that lets someone send files straight into a folder'));1487 return;1488 }1489 const table = h('table.shares-table', {},1490 h('thead', {}, h('tr', {},1491 ...['Folder', 'Link', 'Note', 'Received', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));1492 const tbody = h('tbody');1493 for (const r of requests) {1494 const dead = r.closedAt || (r.expiresAt && r.expiresAt < Date.now())1495 || (r.maxFiles && r.received >= r.maxFiles);1496 tbody.append(h('tr', { style: dead ? { opacity: 0.55 } : {} },1497 h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },1498 h('span', { html: folderIcon({}), style: { display: 'contents' } }),1499 h('button.crumb', { onclick: () => go(`folder/${r.folderId}`) }, r.folderName || 'My Drive'))),1500 h('td', {}, h('div.url-cell', {},1501 h('span', {}, `/r/${r.token}`),1502 h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(r.url, 'URL copied') }),1503 h('button.btn.icon.ghost', {1504 html: UI.qr, title: 'QR code',1505 onclick: () => modal({1506 title: 'QR code',1507 body: h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${r.id}/qr`, width: 220, height: 220, alt: 'QR code' }),1508 h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, r.url)),1509 actions: [{ label: 'Close', primary: true, onClick: () => {} }],1510 }),1511 }))),1512 h('td', {}, r.label ?? '—'),1513 h('td', {}, `${r.received}${r.maxFiles ? ` / ${r.maxFiles}` : ''}`),1514 h('td', {}, r.closedAt ? 'closed' : !r.expiresAt ? 'never' : r.expiresAt < Date.now() ? 'expired' : countdown(r.expiresAt)),1515 h('td', {}, h('span.chip', { style: dead ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },1516 dead ? 'inactive' : 'active')),1517 h('td', {}, !r.closedAt ? h('button.btn.icon.ghost.danger', {1518 html: UI.close, title: 'Close link',1519 onclick: async () => { await del(`/api/v1/requests/${r.id}`); toast('Request closed'); loadRequests(); },1520 }) : ''),1521 ));1522 }1523 table.append(tbody);1524 content.append(table);1525}15261527// ── Storage insights ─────────────────────────────────────────────────1528async function loadStorage() {1529 toolbar.innerHTML = '';1530 toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Storage')));1531 content.innerHTML = '<div class="grid">' + '<div class="skeleton"></div>'.repeat(3) + '</div>';1532 const s = await getJSON('/api/v1/stats/detailed');1533 content.innerHTML = '';15341535 const stat = (label, value, sub) => h('div.stat-tile', {},1536 h('div.v', {}, value), h('div.l', {}, label), sub ? h('div.s', {}, sub) : '');1537 content.append(h('div.stat-row', {},1538 stat('Used space', fmtSize(s.usedBytes), `${s.nodeCount} items · ${s.blobCount} unique blobs`),1539 stat('Saved by dedup', fmtSize(s.dedupSavedBytes), 'identical content stored once'),1540 stat('Version history', fmtSize(s.versionBytes), `${s.versionCount} kept version(s)`),1541 stat('In trash', fmtSize(s.trashBytes), `${s.trashCount} file(s) — auto-purged after 30 days`),1542 ));15431544 // Largest files1545 const largestWrap = h('div.panel', {}, h('h3', {}, 'Largest files'));1546 const maxSize = s.largest[0]?.size || 1;1547 for (const f of s.largest) {1548 largestWrap.append(h('div.big-file', {1549 onclick: () => go(`folder/${f.parentId}`), title: `Open ${f.path}`,1550 },1551 h('span', { html: nodeIcon(f), style: { display: 'contents' } }),1552 h('div.bf-main', {},1553 h('div.bf-name', {}, f.name, h('span.bf-path', {}, ` ${f.path.slice(0, f.path.lastIndexOf('/') + 1)}`)),1554 h('div.bf-bar', {}, h('i', { style: { width: `${(f.size / maxSize) * 100}%` } }))),1555 h('span.bf-size', {}, fmtSize(f.size))));1556 }1557 if (!s.largest.length) largestWrap.append(h('p.muted', {}, 'No files yet.'));15581559 // Duplicates1560 const dupWrap = h('div.panel', {}, h('h3', {}, 'Duplicate files'),1561 h('p.muted', { style: { fontSize: '12.5px', margin: '0 0 10px' } },1562 'Copies share one blob on disk (no wasted space) — listed here so you can tidy up.'));1563 if (!s.duplicates.length) dupWrap.append(h('p.muted', {}, 'No duplicates — nice and tidy.'));1564 for (const g of s.duplicates) {1565 const group = h('div.dup-group', {},1566 h('div.dup-head', {},1567 h('strong', {}, `${g.copies} copies`), ` · ${fmtSize(g.size)} each`,1568 h('span.mono', { style: { color: 'var(--muted)', fontSize: '10.5px', marginLeft: '8px' } }, g.sha.slice(0, 12))));1569 for (const n of g.nodes) {1570 group.append(h('div.dup-row', {},1571 h('button.crumb', { onclick: () => go(`folder/${n.parentId}`) }, n.path),1572 h('button.btn.icon.ghost.danger', {1573 html: UI.trash, title: 'Move this copy to trash',1574 onclick: async () => { await del(`/api/v1/nodes/${n.id}`); toast('Moved to trash'); loadStorage(); },1575 })));1576 }1577 dupWrap.append(group);1578 }15791580 content.append(h('div.storage-page', {}, largestWrap, dupWrap));1581}15821583// ── Activity ─────────────────────────────────────────────────────────1584const ACT_ICONS = {1585 'file.upload': UI.upload, 'file.download_zip': UI.download, 'folder.create': UI.folderNew,1586 'node.rename': UI.rename, 'node.move': UI.move, 'node.copy': UI.copy, 'node.trash': UI.trash,1587 'node.restore': UI.restore, 'node.delete_forever': UI.trash, 'node.star': UI.starO,1588 'share.create': UI.share, 'share.visit': UI.eye, 'share.download': UI.download,1589 'share.revoke': UI.close, 'auth.login': UI.check, 'auth.login_failed': UI.close,1590 'file.edit': UI.edit, 'file.replace': UI.upload, 'file.restore': UI.history,1591 'request.create': UI.inbox, 'request.upload': UI.inbox, 'request.close': UI.close,1592};15931594async function loadActivity() {1595 const { events } = await getJSON('/api/v1/activity?limit=300');1596 toolbar.innerHTML = '';1597 toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Activity')));1598 content.innerHTML = '';1599 const list = h('div.activity-list');1600 for (const ev of events) {1601 list.append(h('div.act-row', {},1602 h('span', { html: ACT_ICONS[ev.kind] ?? UI.activity, style: { display: 'contents' } }),1603 h('div', {},1604 h('div', {}, `${ev.kind.replace(/[._]/g, ' ')}${ev.node_name ? ` — ${ev.node_name}` : ''}`),1605 ev.detail || ev.ip ? h('div', { style: { color: 'var(--muted)', fontSize: '11.5px' } }, [ev.detail, ev.ip].filter(Boolean).join(' · ')) : ''),1606 h('span.when', {}, fmtDate(ev.ts))));1607 }1608 content.append(list.children.length ? list : emptyState('search', 'No activity yet', ''));1609}16101611// ── Settings ─────────────────────────────────────────────────────────1612async function loadSettings() {1613 toolbar.innerHTML = '';1614 toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Settings')));1615 content.innerHTML = '';1616 const wrap = h('div.settings');16171618 // Change password1619 const cur = h('input', { type: 'password', autocomplete: 'current-password' });1620 const next = h('input', { type: 'password', autocomplete: 'new-password' });1621 const next2 = h('input', { type: 'password', autocomplete: 'new-password' });1622 wrap.append(h('div.panel', {},1623 h('h3', {}, 'Change password'),1624 h('label.field', {}, h('span', {}, 'Current password'), cur),1625 h('label.field', {}, h('span', {}, 'New password'), next),1626 h('label.field', {}, h('span', {}, 'Repeat new password'), next2),1627 h('button.btn.primary', {1628 onclick: async () => {1629 if (next.value !== next2.value) { toast('Passwords do not match', { error: true }); return; }1630 try {1631 await post('/api/v1/auth/password', { current: cur.value, next: next.value });1632 toast('Password changed');1633 cur.value = next.value = next2.value = '';1634 } catch (err) { toast(err.message, { error: true }); }1635 },1636 }, 'Update password')));16371638 // Sessions1639 const sessionsPanel = h('div.panel', {}, h('h3', {}, 'Active sessions'));1640 const renderSessions = async () => {1641 [...sessionsPanel.querySelectorAll('.session-row, .btn.danger')].forEach((n) => n.remove());1642 const { sessions } = await getJSON('/api/v1/auth/sessions');1643 for (const s of sessions) {1644 sessionsPanel.append(h('div.session-row', {},1645 h('div.who', {},1646 h('div', {}, `${s.ip || 'unknown ip'} ${s.remember ? '· remembered' : ''}`),1647 h('div.ua', {}, s.ua ?? '')),1648 h('span.muted', { style: { fontSize: '11.5px' } }, `seen ${fmtDate(s.last_seen)}`),1649 h('button.btn.icon.ghost.danger', {1650 html: UI.close, title: 'Revoke',1651 onclick: async () => { await del(`/api/v1/auth/sessions/${s.id}`); renderSessions(); },1652 })));1653 }1654 sessionsPanel.append(h('button.btn.danger', {1655 style: { marginTop: '12px' },1656 onclick: async () => {1657 if (await confirmModal({ title: 'Logout everywhere', message: 'Revoke every session including this one?', confirmLabel: 'Logout everywhere', danger: true })) {1658 await del('/api/v1/auth/sessions');1659 location.href = '/login';1660 }1661 },1662 }, 'Logout everywhere'));1663 };1664 renderSessions();1665 wrap.append(sessionsPanel);16661667 // API tokens1668 const tokensPanel = h('div.panel', {}, h('h3', {}, 'API tokens (CLI)'));1669 const renderTokens = async () => {1670 [...tokensPanel.querySelectorAll('.session-row, .tok-new')].forEach((n) => n.remove());1671 const { tokens } = await getJSON('/api/v1/auth/api-tokens');1672 for (const t of tokens) {1673 tokensPanel.append(h('div.session-row', {},1674 h('div.who', {}, h('div', {}, t.name), h('div.ua', {}, `created ${fmtDate(t.created)} · last used ${fmtDate(t.last_used)}`)),1675 h('button.btn.icon.ghost.danger', {1676 html: UI.close, title: 'Revoke',1677 onclick: async () => { await del(`/api/v1/auth/api-tokens/${t.id}`); renderTokens(); },1678 })));1679 }1680 const nameInput = h('input', { type: 'text', placeholder: 'Token name (e.g. laptop-cli)', style: { width: '220px' } });1681 tokensPanel.append(h('div.tok-new', { style: { display: 'flex', gap: '8px', marginTop: '12px' } },1682 nameInput,1683 h('button.btn.primary', {1684 onclick: async () => {1685 const { token } = await post('/api/v1/auth/api-tokens', { name: nameInput.value || 'token' });1686 modal({1687 title: 'API token created',1688 body: h('div', {},1689 h('p', { style: { color: 'var(--muted)', fontSize: '13px' } }, 'Copy it now — it will not be shown again. Use it with `spbdrive init`.'),1690 h('div.share-url', {},1691 h('input', { type: 'text', value: token, readonly: true, onclick: (e) => e.target.select() }),1692 h('button.btn.primary', { onclick: () => copyText(token, 'Token copied') }, 'Copy'))),1693 actions: [{ label: 'Done', primary: true, onClick: () => {} }],1694 });1695 renderTokens();1696 },1697 }, 'Create token')));1698 };1699 renderTokens();1700 wrap.append(tokensPanel);17011702 // Appearance + logout1703 wrap.append(h('div.panel', {},1704 h('h3', {}, 'Appearance'),1705 h('button.btn', { onclick: toggleTheme }, 'Toggle dark / light theme'),1706 h('span.muted', { style: { marginLeft: '10px', fontSize: '12px' } }, 'Preference is saved in this browser.')));1707 wrap.append(h('div.panel', {},1708 h('h3', {}, 'Session'),1709 h('a.btn', { href: '/logout' }, h('span', { html: UI.logout, style: { display: 'contents' } }), 'Log out')));17101711 content.append(wrap);1712}17131714// ── Rectangle select ─────────────────────────────────────────────────1715function wireRectangleSelect(host) {1716 let start = null; let rect = null;1717 content.onpointerdown = (e) => {1718 if (e.button !== 0 || e.target.closest('[data-id], button, input, a, select')) return;1719 start = { x: e.clientX, y: e.clientY };1720 if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {1721 state.selection.clear();1722 paintSelection();1723 }1724 };1725 content.onpointermove = (e) => {1726 if (!start) return;1727 if (!rect) {1728 if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < 6) return;1729 rect = h('div.select-rect');1730 document.body.append(rect);1731 }1732 const x = Math.min(start.x, e.clientX); const y = Math.min(start.y, e.clientY);1733 const w = Math.abs(e.clientX - start.x); const hgt = Math.abs(e.clientY - start.y);1734 Object.assign(rect.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${hgt}px`, position: 'fixed' });1735 const box = { left: x, top: y, right: x + w, bottom: y + hgt };1736 host.querySelectorAll('[data-id]').forEach((el) => {1737 const r = el.getBoundingClientRect();1738 const hit = !(r.right < box.left || r.left > box.right || r.bottom < box.top || r.top > box.bottom);1739 const id = Number(el.dataset.id);1740 hit ? state.selection.add(id) : state.selection.delete(id);1741 });1742 paintSelection();1743 };1744 const end = () => { start = null; rect?.remove(); rect = null; };1745 content.onpointerup = end;1746 content.onpointerleave = end;1747}17481749// ── Keyboard shortcuts ───────────────────────────────────────────────1750function onGlobalKey(e) {1751 const inInput = e.target.matches('input, textarea, select, [contenteditable]');1752 if (e.key === '/' && !inInput) { e.preventDefault(); $('searchInput').focus(); return; }1753 if (inInput) return;1754 if (document.querySelector('.preview-overlay, .modal-scrim, .ctx-menu')) return;17551756 const focusedId = state.anchor;1757 const node = focusedId ? nodeById(focusedId) : null;17581759 if (e.key === '?') { showShortcuts(); return; }1760 if (e.key === 'Enter' && node) { openNode(node); return; }1761 if (e.key === ' ' && node && node.type === 'file') { e.preventDefault(); openViewer(node); return; }1762 if (e.key === 'F2' && node) { e.preventDefault(); inlineRename(node); return; }1763 if ((e.key === 'Delete' || e.key === 'Backspace') && state.selection.size) {1764 e.preventDefault();1765 state.route.view === 'trash' ? bulkDeleteForever([...state.selection]) : bulkTrash([...state.selection]);1766 return;1767 }1768 if (e.key.toLowerCase() === 's' && node && !e.metaKey && !e.ctrlKey) { toggleStar(node); return; }1769 if (e.key.toLowerCase() === 'e' && node && !e.metaKey && !e.ctrlKey && isEditable(node)) { openEditor(node); return; }1770 if (e.key.toLowerCase() === 'a' && (e.metaKey || e.ctrlKey)) {1771 e.preventDefault();1772 state.selection = new Set(state.nodes.map((n) => n.id));1773 paintSelection();1774 return;1775 }1776 if (e.key.toLowerCase() === 'v' && !e.metaKey && !e.ctrlKey) { toggleViewMode(); return; }1777 if (e.key.toLowerCase() === 'n' && !e.metaKey && !e.ctrlKey && state.route.view === 'folder') { newFolderDialog(); return; }17781779 // Arrow navigation1780 if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) && state.nodes.length) {1781 e.preventDefault();1782 const ids = state.nodes.map((n) => n.id);1783 let idx = focusedId ? ids.indexOf(focusedId) : -1;1784 const cols = state.viewMode === 'grid'1785 ? Math.max(1, Math.floor(content.querySelector('.grid')?.clientWidth / (state.cardSize + 12)) || 1)1786 : 1;1787 const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key];1788 idx = Math.min(Math.max(idx + delta, 0), ids.length - 1);1789 const id = ids[idx];1790 state.anchor = id;1791 if (e.shiftKey) state.selection.add(id);1792 else state.selection = new Set([id]);1793 paintSelection();1794 content.querySelector(`[data-id="${id}"]`)?.scrollIntoView({ block: 'nearest' });1795 const n = nodeById(id);1796 if (n) showInfo(n);1797 }1798}17991800function showShortcuts() {1801 const rows = [1802 ['Navigate', '← → ↑ ↓'], ['Open / enter folder', '↵'], ['Quick look', 'Space'],1803 ['Rename', 'F2'], ['Move to trash', 'Del'], ['Select all', '⌘A'],1804 ['Extend selection', 'Shift+click'], ['Toggle item', '⌘+click'],1805 ['Star', 'S'], ['Edit text file', 'E'], ['Toggle view', 'V'], ['New folder', 'N'],1806 ['Search', '/'], ['This cheat sheet', '?'], ['Close / cancel', 'Esc'],1807 ];1808 modal({1809 title: 'Keyboard shortcuts',1810 wide: true,1811 body: h('div.shortcuts-grid', {}, rows.map(([label, key]) =>1812 h('div', {}, h('span', {}, label), h('kbd', {}, key)))),1813 actions: [{ label: 'Close', primary: true, onClick: () => {} }],1814 });1815}18161817boot();1818