/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/assets/js/ui.js * Purpose : UI primitives — DOM helper, toasts, modals, context menu, formats * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ /** Tiny hyperscript: h('button.btn.primary', {onclick}, 'Save'). */ export function h(spec, attrs = {}, ...children) { const [tag, ...classes] = spec.split('.'); const el = document.createElement(tag || 'div'); if (classes.length) el.className = classes.join(' '); for (const [key, val] of Object.entries(attrs ?? {})) { if (val === undefined || val === null || val === false) continue; if (key.startsWith('on') && typeof val === 'function') el.addEventListener(key.slice(2), val); else if (key === 'html') el.innerHTML = val; else if (key === 'dataset') Object.assign(el.dataset, val); else if (key === 'style' && typeof val === 'object') Object.assign(el.style, val); else el.setAttribute(key, val === true ? '' : val); } for (const child of children.flat(Infinity)) { if (child === null || child === undefined || child === false) continue; el.append(child.nodeType ? child : document.createTextNode(child)); } return el; } export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); export function fmtSize(bytes) { const n = Number(bytes ?? 0); if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; } export function fmtDate(ts) { if (!ts) return '—'; const d = new Date(ts); const now = Date.now(); const diff = now - ts; if (diff < 60_000) return 'just now'; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`; if (diff < 86_400_000 && new Date(now).getDate() === d.getDate()) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); } export function fmtDuration(sec) { if (!Number.isFinite(sec)) return ''; const s = Math.round(sec); const m = Math.floor(s / 60); const r = s % 60; const hh = Math.floor(m / 60); return hh ? `${hh}:${String(m % 60).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`; } // ── Toasts ─────────────────────────────────────────────────────────── export function toast(message, { actionLabel, onAction, error = false, ttl = 4500 } = {}) { const host = document.getElementById('toasts'); if (!host) return; const el = h(`div.toast${error ? '.error' : ''}`, { role: 'status' }, message); if (actionLabel) { el.append(h('button', { onclick: () => { el.remove(); onAction?.(); }, }, actionLabel)); } el.append(h('button', { 'aria-label': 'Dismiss', style: { color: 'var(--muted)' }, onclick: () => el.remove(), }, '✕')); host.append(el); setTimeout(() => el.remove(), ttl); } // ── Modals ─────────────────────────────────────────────────────────── export function modal({ title, body, actions = [], wide = false, onClose }) { const scrim = h('div.modal-scrim', { onclick: (e) => { if (e.target === scrim) close(); }, }); const box = h(`div.modal${wide ? '.wide' : ''}`, { role: 'dialog', 'aria-label': title }); const close = () => { scrim.remove(); document.removeEventListener('keydown', onKey); onClose?.(); }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; document.addEventListener('keydown', onKey); box.append(h('h2', {}, title)); box.append(body); if (actions.length) { box.append(h('div.actions', {}, actions.map(({ label, primary, danger, onClick }) => h(`button.btn${primary ? '.primary' : ''}${danger ? '.danger' : ''}`, { onclick: async () => { if ((await onClick?.(close)) !== false) close(); }, }, label)))); } scrim.append(box); document.body.append(scrim); const first = box.querySelector('input, select, textarea, button'); first?.focus(); return { close, box }; } export function confirmModal({ title, message, confirmLabel = 'Confirm', danger = false, typed = null }) { return new Promise((resolve) => { let input = null; const body = h('div', {}, h('p', { style: { color: 'var(--muted)', margin: '0 0 6px' } }, message)); if (typed) { body.append(h('p', { style: { fontSize: '12.5px' } }, `Type "${typed}" to confirm:`)); input = h('input', { type: 'text' }); body.append(input); } const m = modal({ title, body, onClose: () => resolve(false), actions: [ { label: 'Cancel', onClick: () => resolve(false) }, { label: confirmLabel, primary: !danger, danger, onClick: () => { if (typed && input.value !== typed) { input.style.borderColor = 'var(--danger)'; return false; } resolve(true); return true; }, }, ], }); return m; }); } // ── Context menu ───────────────────────────────────────────────────── let openMenu = null; export function closeContextMenu() { openMenu?.remove(); openMenu = null; } /** * items: {label, icon, danger, kbd, onClick} | {sep: true} | {custom: Element} */ export function contextMenu(x, y, items) { closeContextMenu(); const menu = h('div.ctx-menu', { role: 'menu' }); for (const item of items) { if (!item) continue; if (item.sep) { menu.append(h('div.ctx-sep')); continue; } if (item.custom) { menu.append(item.custom); continue; } const btn = h(`button.ctx-item${item.danger ? '.danger' : ''}`, { role: 'menuitem', onclick: () => { closeContextMenu(); item.onClick?.(); }, }); if (item.icon) btn.append(h('span', { html: item.icon, style: { display: 'contents' } })); btn.append(h('span', {}, item.label)); if (item.kbd) btn.append(h('kbd', {}, item.kbd)); menu.append(btn); } document.body.append(menu); // Mobile: the menu becomes a bottom sheet (thumb-reachable, app-like). if (window.matchMedia('(max-width: 700px)').matches) { menu.classList.add('sheet'); } else { const { innerWidth: vw, innerHeight: vh } = window; const rect = menu.getBoundingClientRect(); menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`; menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`; } openMenu = menu; // Keyboard accessibility: arrows + enter + escape. const focusables = [...menu.querySelectorAll('.ctx-item')]; let idx = -1; const onKey = (e) => { if (e.key === 'Escape') { cleanup(); } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); idx = (idx + (e.key === 'ArrowDown' ? 1 : -1) + focusables.length) % focusables.length; focusables[idx]?.focus(); } }; const onDoc = (e) => { if (!menu.contains(e.target)) cleanup(); }; const cleanup = () => { closeContextMenu(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onDoc, true); document.removeEventListener('contextmenu', onDoc, true); }; document.addEventListener('keydown', onKey, true); document.addEventListener('pointerdown', onDoc, true); document.addEventListener('contextmenu', onDoc, true); focusables[0]?.focus(); return menu; } /** Copy text to the clipboard with a toast. */ export async function copyText(text, label = 'Copied to clipboard') { try { await navigator.clipboard.writeText(text); toast(label); } catch { const ta = h('textarea', { style: { position: 'fixed', opacity: 0 } }, text); document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove(); toast(label); } }