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%
8.5 KB · 212 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/ui.js8 *  Purpose : UI primitives — DOM helper, toasts, modals, context menu, formats9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213/** Tiny hyperscript: h('button.btn.primary', {onclick}, 'Save'). */14export function h(spec, attrs = {}, ...children) {15  const [tag, ...classes] = spec.split('.');16  const el = document.createElement(tag || 'div');17  if (classes.length) el.className = classes.join(' ');18  for (const [key, val] of Object.entries(attrs ?? {})) {19    if (val === undefined || val === null || val === false) continue;20    if (key.startsWith('on') && typeof val === 'function') el.addEventListener(key.slice(2), val);21    else if (key === 'html') el.innerHTML = val;22    else if (key === 'dataset') Object.assign(el.dataset, val);23    else if (key === 'style' && typeof val === 'object') Object.assign(el.style, val);24    else el.setAttribute(key, val === true ? '' : val);25  }26  for (const child of children.flat(Infinity)) {27    if (child === null || child === undefined || child === false) continue;28    el.append(child.nodeType ? child : document.createTextNode(child));29  }30  return el;31}3233export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>34  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));3536export function fmtSize(bytes) {37  const n = Number(bytes ?? 0);38  if (n < 1024) return `${n} B`;39  const units = ['KB', 'MB', 'GB', 'TB'];40  let v = n / 1024; let i = 0;41  while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }42  return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;43}4445export function fmtDate(ts) {46  if (!ts) return '—';47  const d = new Date(ts);48  const now = Date.now();49  const diff = now - ts;50  if (diff < 60_000) return 'just now';51  if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;52  if (diff < 86_400_000 && new Date(now).getDate() === d.getDate()) {53    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });54  }55  return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });56}5758export function fmtDuration(sec) {59  if (!Number.isFinite(sec)) return '';60  const s = Math.round(sec);61  const m = Math.floor(s / 60); const r = s % 60;62  const hh = Math.floor(m / 60);63  return hh ? `${hh}:${String(m % 60).padStart(2, '0')}:${String(r).padStart(2, '0')}`64    : `${m}:${String(r).padStart(2, '0')}`;65}6667// ── Toasts ───────────────────────────────────────────────────────────68export function toast(message, { actionLabel, onAction, error = false, ttl = 4500 } = {}) {69  const host = document.getElementById('toasts');70  if (!host) return;71  const el = h(`div.toast${error ? '.error' : ''}`, { role: 'status' }, message);72  if (actionLabel) {73    el.append(h('button', {74      onclick: () => { el.remove(); onAction?.(); },75    }, actionLabel));76  }77  el.append(h('button', {78    'aria-label': 'Dismiss', style: { color: 'var(--muted)' },79    onclick: () => el.remove(),80  }, '✕'));81  host.append(el);82  setTimeout(() => el.remove(), ttl);83}8485// ── Modals ───────────────────────────────────────────────────────────86export function modal({ title, body, actions = [], wide = false, onClose }) {87  const scrim = h('div.modal-scrim', {88    onclick: (e) => { if (e.target === scrim) close(); },89  });90  const box = h(`div.modal${wide ? '.wide' : ''}`, { role: 'dialog', 'aria-label': title });91  const close = () => { scrim.remove(); document.removeEventListener('keydown', onKey); onClose?.(); };92  const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };93  document.addEventListener('keydown', onKey);94  box.append(h('h2', {}, title));95  box.append(body);96  if (actions.length) {97    box.append(h('div.actions', {}, actions.map(({ label, primary, danger, onClick }) =>98      h(`button.btn${primary ? '.primary' : ''}${danger ? '.danger' : ''}`, {99        onclick: async () => { if ((await onClick?.(close)) !== false) close(); },100      }, label))));101  }102  scrim.append(box);103  document.body.append(scrim);104  const first = box.querySelector('input, select, textarea, button');105  first?.focus();106  return { close, box };107}108109export function confirmModal({ title, message, confirmLabel = 'Confirm', danger = false, typed = null }) {110  return new Promise((resolve) => {111    let input = null;112    const body = h('div', {}, h('p', { style: { color: 'var(--muted)', margin: '0 0 6px' } }, message));113    if (typed) {114      body.append(h('p', { style: { fontSize: '12.5px' } }, `Type "${typed}" to confirm:`));115      input = h('input', { type: 'text' });116      body.append(input);117    }118    const m = modal({119      title,120      body,121      onClose: () => resolve(false),122      actions: [123        { label: 'Cancel', onClick: () => resolve(false) },124        {125          label: confirmLabel, primary: !danger, danger,126          onClick: () => {127            if (typed && input.value !== typed) { input.style.borderColor = 'var(--danger)'; return false; }128            resolve(true);129            return true;130          },131        },132      ],133    });134    return m;135  });136}137138// ── Context menu ─────────────────────────────────────────────────────139let openMenu = null;140export function closeContextMenu() { openMenu?.remove(); openMenu = null; }141142/**143 * items: {label, icon, danger, kbd, onClick} | {sep: true} | {custom: Element}144 */145export function contextMenu(x, y, items) {146  closeContextMenu();147  const menu = h('div.ctx-menu', { role: 'menu' });148  for (const item of items) {149    if (!item) continue;150    if (item.sep) { menu.append(h('div.ctx-sep')); continue; }151    if (item.custom) { menu.append(item.custom); continue; }152    const btn = h(`button.ctx-item${item.danger ? '.danger' : ''}`, {153      role: 'menuitem',154      onclick: () => { closeContextMenu(); item.onClick?.(); },155    });156    if (item.icon) btn.append(h('span', { html: item.icon, style: { display: 'contents' } }));157    btn.append(h('span', {}, item.label));158    if (item.kbd) btn.append(h('kbd', {}, item.kbd));159    menu.append(btn);160  }161  document.body.append(menu);162  // Mobile: the menu becomes a bottom sheet (thumb-reachable, app-like).163  if (window.matchMedia('(max-width: 700px)').matches) {164    menu.classList.add('sheet');165  } else {166    const { innerWidth: vw, innerHeight: vh } = window;167    const rect = menu.getBoundingClientRect();168    menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;169    menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;170  }171  openMenu = menu;172173  // Keyboard accessibility: arrows + enter + escape.174  const focusables = [...menu.querySelectorAll('.ctx-item')];175  let idx = -1;176  const onKey = (e) => {177    if (e.key === 'Escape') { cleanup(); }178    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {179      e.preventDefault();180      idx = (idx + (e.key === 'ArrowDown' ? 1 : -1) + focusables.length) % focusables.length;181      focusables[idx]?.focus();182    }183  };184  const onDoc = (e) => { if (!menu.contains(e.target)) cleanup(); };185  const cleanup = () => {186    closeContextMenu();187    document.removeEventListener('keydown', onKey, true);188    document.removeEventListener('pointerdown', onDoc, true);189    document.removeEventListener('contextmenu', onDoc, true);190  };191  document.addEventListener('keydown', onKey, true);192  document.addEventListener('pointerdown', onDoc, true);193  document.addEventListener('contextmenu', onDoc, true);194  focusables[0]?.focus();195  return menu;196}197198/** Copy text to the clipboard with a toast. */199export async function copyText(text, label = 'Copied to clipboard') {200  try {201    await navigator.clipboard.writeText(text);202    toast(label);203  } catch {204    const ta = h('textarea', { style: { position: 'fixed', opacity: 0 } }, text);205    document.body.append(ta);206    ta.select();207    document.execCommand('copy');208    ta.remove();209    toast(label);210  }211}212