/** * ───────────────────────────────────────────── * 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 = '
' + '
'.repeat(8) + '
'; 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 = '
' + '
'.repeat(3) + '
'; 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();