spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/assets/js/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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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