SPB Git

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%
76.1 KB · 1,818 lines javascript
Raw Blame History
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