Spaces:
Running
Running
Download client/src/common.js from lvwerra/cowrite-dev: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/common.js
- Command line
-
hf download hf://spaces/lvwerra/cowrite-dev/client/src/common.js
-
curl -L -o common.js https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/common.js
20.9 kB
| // Shared client helpers: auth bootstrap, fetch wrapper, DOM utils, SVG icons. | |
| const ICONS = { | |
| file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>', | |
| folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>', | |
| star: '<path d="M11 3.5l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z"/>', | |
| tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>', | |
| key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>', | |
| todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>', | |
| table: '<rect x="3.5" y="4.5" width="15" height="13" rx="1.5"/><path d="M3.5 9h15M3.5 13.5h15M8.5 4.5v13M13.5 4.5v13"/>', | |
| eye: '<path d="M2.5 11s3.2-5.5 8.5-5.5 8.5 5.5 8.5 5.5-3.2 5.5-8.5 5.5S2.5 11 2.5 11z"/><circle cx="11" cy="11" r="2.4"/>', | |
| eyeoff: '<path d="M4 4l14 14M9 5.8A8.7 8.7 0 0 1 11 5.5c5.3 0 8.5 5.5 8.5 5.5a15 15 0 0 1-2.7 3.2M6 7.5A14 14 0 0 0 2.5 11S5.7 16.5 11 16.5a8.6 8.6 0 0 0 3-.5"/>', | |
| caret: '<path d="M6 9l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>', | |
| back: '<path d="M15 4l-7 7 7 7"/>', | |
| sun: '<circle cx="11" cy="11" r="4"/><path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17"/>', | |
| moon: '<path d="M17 13.2A7 7 0 0 1 8.8 5a7.2 7.2 0 1 0 8.2 8.2z"/>', | |
| serif: '<path d="M4.5 6V4.5h13V6M11 4.5v13M8 17.5h6"/>', | |
| docpage: '<rect x="4.5" y="3.5" width="13" height="15" rx="1.5"/><path d="M7.5 8h7M7.5 11.5h7M7.5 15h4"/>', | |
| comment: '<path d="M19 12.5a6.5 6.5 0 0 1-6.5 6.5H5l-1.8 1.8V12.5a7.3 7.3 0 0 1 7.3-7.3h1.5A6.5 6.5 0 0 1 19 12.5z"/>', | |
| // The same bubble with a plus in it: "leave a comment here", as opposed to | |
| // `comment`, which marks one that already exists. The bubble is redrawn a | |
| // little smaller and rounder rather than reused, because the plus needs room | |
| // in the middle and the pointed tail reads as noise at 15px. | |
| commentadd: '<path d="M18.5 11.5a6 6 0 0 1-6 6H7l-3.5 2.5v-8.5a6 6 0 0 1 6-6h3a6 6 0 0 1 6 6z"/><path d="M11 8.5v6M8 11.5h6"/>', | |
| edit: '<path d="M14.5 4.5l3 3L8 17l-4 1 1-4 9.5-9.5z"/>', | |
| image: '<rect x="3.5" y="4.5" width="15" height="13" rx="2"/><circle cx="8" cy="9" r="1.4"/><path d="M18 14l-4-4-7 7"/>', | |
| link: '<path d="M9 12.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1.2 1.2"/><path d="M13 9.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1.2-1.2"/>', | |
| // the link glyph with its middle struck through: remove this link | |
| unlink: | |
| '<path d="M9.5 13a3.5 3.5 0 0 0 4.3-.5l2.7-2.7a3.5 3.5 0 0 0-4.7-5.1"/><path d="M12.5 9a3.5 3.5 0 0 0-4.3.5l-2.7 2.7a3.5 3.5 0 0 0 4.7 5.1"/><path d="M4 4l14 14"/>', | |
| download: '<path d="M11 3.5v10"/><path d="M6.5 9.5l4.5 4.5 4.5-4.5"/><path d="M4.5 17.5h13"/>', | |
| copy: '<rect x="8" y="8" width="10" height="11" rx="2"/><path d="M5.5 14.5H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 2 2v.5"/>', | |
| check: '<path d="M4.5 11.5l4.5 4.5 8.5-9.5"/>', | |
| x: '<path d="M6 6l10 10M16 6L6 16"/>', | |
| reply: '<path d="M18 5v4a4 4 0 0 1-4 4H5"/><path d="M8.5 9.5L5 13l3.5 3.5"/>', | |
| send: '<path d="M4 11l14-6-4.5 14-3-6L4 11z"/>', | |
| trash: '<path d="M4.5 6.5h13M9 6V4.5h4V6M6.5 6.5l.8 12h7.4l.8-12"/>', | |
| quote: '<path d="M8.5 6.5c-2.5 1-4 3-4 6.5v3h4.5v-5h-2.7c.2-2 1-3.3 2.2-4.5zM18 6.5c-2.5 1-4 3-4 6.5v3h4.5v-5h-2.7c.2-2 1-3.3 2.2-4.5z"/>', | |
| undo: '<path d="M8.5 5.5L4 10l4.5 4.5"/><path d="M4 10h9a5 4.5 0 0 1 0 9h-3"/>', | |
| redo: '<path d="M13.5 5.5L18 10l-4.5 4.5"/><path d="M18 10H9a5 4.5 0 0 0 0 9h3"/>', | |
| zoomIn: '<circle cx="10" cy="10" r="6.5"/><path d="M15 15l4 4M10 7.5v5M7.5 10h5"/>', | |
| zoomOut: '<circle cx="10" cy="10" r="6.5"/><path d="M15 15l4 4M7.5 10h5"/>', | |
| } | |
| export function icon(name) { | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') | |
| svg.setAttribute('viewBox', '0 0 22 22') | |
| svg.setAttribute('class', 'icon') | |
| svg.innerHTML = ICONS[name] || '' | |
| return svg | |
| } | |
| export async function initAuth(nextPath) { | |
| // the shell inlines this; the fetch is only for shells served without it | |
| const res = window.__BOOT || (await fetch('/api/me').then(r => r.json())) | |
| const whoami = document.getElementById('whoami') | |
| // Opened through a public link: not signed in, but not turned away either. | |
| // No username, so every "is this mine?" check in the app answers no. | |
| if (!res.user && res.share) { | |
| whoami.replaceChildren(el('span', { class: 'muted' }, 'Read-only link')) | |
| if (res.oauth) { | |
| const inLink = document.createElement('a') | |
| inLink.href = `/auth/login?next=${encodeURIComponent(nextPath || '/')}` | |
| inLink.textContent = 'sign in' | |
| whoami.append(document.createTextNode(' · '), inLink) | |
| } | |
| return { username: null, name: 'Viewer', avatar: null, viewer: true, isAdmin: false, buildId: res.build_id || null } | |
| } | |
| if (res.user) { | |
| res.user.buildId = res.build_id || null | |
| whoami.replaceChildren() | |
| if (res.user.avatar) { | |
| const img = document.createElement('img') | |
| img.src = res.user.avatar | |
| whoami.appendChild(img) | |
| } | |
| whoami.appendChild(document.createTextNode(res.user.username + ' ')) | |
| const out = document.createElement('a') | |
| out.href = '/auth/logout' | |
| out.textContent = 'sign out' | |
| whoami.appendChild(out) | |
| return { ...res.user, isAdmin: res.is_admin === true } | |
| } | |
| const overlay = document.getElementById('signin') | |
| overlay.classList.remove('hidden') | |
| if (!res.oauth) { | |
| document.getElementById('signin-btn').classList.add('hidden') | |
| document.getElementById('dev-login').classList.remove('hidden') | |
| document.getElementById('dev-go').addEventListener('click', () => { | |
| const u = document.getElementById('dev-user').value.trim() | |
| if (u) location.href = `/auth/dev?u=${encodeURIComponent(u)}&next=${encodeURIComponent(nextPath)}` | |
| }) | |
| } else { | |
| // Plain in-place navigation, also inside the huggingface.co iframe: the HF | |
| // OAuth page renders there, while target="_top" clicks get blocked by the | |
| // iframe sandbox. | |
| document.getElementById('signin-btn').href = `/auth/login?next=${encodeURIComponent(nextPath)}` | |
| } | |
| return null | |
| } | |
| export async function api(path, { method = 'GET', body } = {}) { | |
| const res = await fetch(path, { | |
| method, | |
| headers: body ? { 'content-type': 'application/json' } : undefined, | |
| body: body ? JSON.stringify(body) : undefined, | |
| }) | |
| try { | |
| return await res.json() | |
| } catch { | |
| return { error: `HTTP ${res.status}` } | |
| } | |
| } | |
| export function el(tag, attrs = {}, text) { | |
| const node = document.createElement(tag) | |
| for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v) | |
| if (text != null) node.textContent = text | |
| return node | |
| } | |
| export function esc(s) { | |
| return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) | |
| } | |
| export function renderMentionText(text) { | |
| return esc(text).replace(/@([a-z0-9][a-z0-9_.-]{1,38})/g, '<span class="mention">@$1</span>') | |
| } | |
| // --- theme ------------------------------------------------------------------ | |
| // The head script has already stamped data-theme. This wires the button that | |
| // overrides it, and keeps following the OS while no explicit choice is stored. | |
| export function wireTheme(btn) { | |
| const media = matchMedia('(prefers-color-scheme: dark)') | |
| const stored = () => { | |
| try { | |
| const t = localStorage.getItem('cw-theme') | |
| return t === 'dark' || t === 'light' ? t : null | |
| } catch { | |
| return null | |
| } | |
| } | |
| const paint = () => { | |
| const dark = document.documentElement.dataset.theme === 'dark' | |
| if (!btn) return | |
| btn.replaceChildren(icon(dark ? 'sun' : 'moon'), el('span', { class: 'lbl' }, dark ? 'Light' : 'Dark')) | |
| btn.title = dark ? 'Switch to light theme' : 'Switch to dark theme' | |
| btn.setAttribute('aria-label', btn.title) | |
| } | |
| const apply = theme => { | |
| document.documentElement.dataset.theme = theme | |
| paint() | |
| } | |
| if (!stored()) apply(media.matches ? 'dark' : 'light') | |
| paint() | |
| media.addEventListener('change', e => { | |
| if (!stored()) apply(e.matches ? 'dark' : 'light') | |
| }) | |
| btn?.addEventListener('click', () => { | |
| const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark' | |
| try { | |
| localStorage.setItem('cw-theme', next) | |
| } catch {} | |
| apply(next) | |
| }) | |
| } | |
| // --- document style --------------------------------------------------------- | |
| // What the document itself is set in: 'docs' (Arial 11pt on a letter sheet) or | |
| // 'reading' (a serif article column). Unlike the theme this has no OS signal to | |
| // follow, so the default is simply 'docs'. The head script has already stamped | |
| // the stored choice; this wires the button that changes it. | |
| export const DOC_STYLES = ['docs', 'reading'] | |
| // onChange fires after a switch has been stamped, for the things that read | |
| // --doc-size back out of the stylesheet — the reader text-size readout is set in | |
| // px, and 100% of Docs and 100% of Reading are not the same number of them. | |
| export function wireDocStyle(btn, onChange) { | |
| const paint = () => { | |
| const reading = document.documentElement.dataset.docStyle === 'reading' | |
| if (!btn) return | |
| btn.replaceChildren(icon(reading ? 'docpage' : 'serif'), el('span', { class: 'lbl' }, reading ? 'Docs' : 'Reading')) | |
| btn.title = reading ? 'Set the document in Arial, like Google Docs' : 'Set the document in a serif reading column' | |
| btn.setAttribute('aria-label', btn.title) | |
| } | |
| const apply = style => { | |
| document.documentElement.dataset.docStyle = style | |
| // the serif is only fetched once something on the page asks for it, so the | |
| // first switch is also the first request — warm it alongside the repaint | |
| if (style === 'reading') preloadSerif() | |
| paint() | |
| onChange?.(style) | |
| } | |
| paint() | |
| btn?.addEventListener('click', () => { | |
| const next = document.documentElement.dataset.docStyle === 'reading' ? 'docs' : 'reading' | |
| try { | |
| localStorage.setItem('cw-doc-style', next) | |
| } catch {} | |
| apply(next) | |
| }) | |
| } | |
| function preloadSerif() { | |
| if (document.getElementById('serif-preload')) return | |
| const l = document.createElement('link') | |
| l.id = 'serif-preload' | |
| l.rel = 'preload' | |
| l.as = 'font' | |
| l.type = 'font/woff2' | |
| l.crossOrigin = 'anonymous' | |
| l.href = '/fonts/libre-baskerville-latin.woff2' | |
| document.head.appendChild(l) | |
| } | |
| // --- in-app dialogs --------------------------------------------------------- | |
| // Native confirm/alert/prompt cannot be used in this app. It is served inside an | |
| // iframe on huggingface.co, and Chrome suppresses dialogs from cross-origin | |
| // iframes: confirm() returns false without ever asking, so every action guarded | |
| // by one silently did nothing (that is what "the remove command doesn't work" | |
| // was). These are the replacements — a real modal, and a toast for errors that | |
| // do not need an acknowledgement. | |
| // Their styles are injected from here rather than added to app.css so the | |
| // helpers stay one self-contained unit; they only use the app's own variables. | |
| const DIALOG_CSS = ` | |
| .ui-modal-scrim { | |
| position: fixed; inset: 0; z-index: 200; padding: 20px; | |
| background: var(--scrim, rgba(18, 18, 24, 0.4)); backdrop-filter: blur(2px); | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .ui-modal { | |
| width: min(430px, 100%); padding: 18px 18px 14px; | |
| background: var(--surface, #fff); color: var(--text, #1a1a1e); | |
| border: 1px solid var(--border, #e7e7ea); border-radius: 12px; | |
| box-shadow: var(--shadow-md, 0 16px 48px rgba(0, 0, 0, 0.22)); | |
| font: 13.5px/1.5 var(--ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); | |
| animation: ui-modal-in 0.13s ease-out; | |
| } | |
| @keyframes ui-modal-in { from { opacity: 0; transform: translateY(-4px) scale(0.99); } } | |
| @media (prefers-reduced-motion: reduce) { .ui-modal { animation: none; } } | |
| .ui-modal-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 4px; } | |
| .ui-modal-msg { color: var(--text-2, #55555e); white-space: pre-wrap; overflow-wrap: anywhere; } | |
| .ui-modal-field { display: block; margin-top: 12px; } | |
| .ui-modal-field input { | |
| width: 100%; margin-top: 5px; padding: 8px 10px; font: inherit; | |
| color: var(--text, #1a1a1e); background: var(--input-bg, #fff); | |
| border: 1px solid var(--border-strong, #d9d9de); border-radius: 8px; | |
| } | |
| .ui-modal-field input:focus { outline: none; border-color: var(--accent, #1d5c45); } | |
| .ui-modal-bar { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } | |
| .ui-modal .btn.ui-danger { | |
| background: var(--danger, #b03030); border-color: var(--danger, #b03030); color: var(--on-primary, #fff); | |
| } | |
| .ui-modal .btn.ui-danger:hover { filter: brightness(1.08); } | |
| .ui-toast-host { | |
| position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 210; | |
| display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none; | |
| } | |
| .ui-toast { | |
| max-width: min(520px, 88vw); padding: 8px 14px; border-radius: 999px; | |
| background: var(--tooltip-bg, #223029); color: #fff; | |
| font: 12.5px/1.45 var(--ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); | |
| box-shadow: var(--shadow-md, 0 4px 16px rgba(20, 20, 30, 0.3)); | |
| opacity: 0; transform: translateY(6px); transition: opacity 0.16s ease, transform 0.16s ease; | |
| } | |
| .ui-toast.in { opacity: 1; transform: none; } | |
| ` | |
| function ensureDialogCss() { | |
| if (document.getElementById('ui-dialog-css')) return | |
| const style = document.createElement('style') | |
| style.id = 'ui-dialog-css' | |
| style.textContent = DIALOG_CSS | |
| document.head.appendChild(style) | |
| } | |
| const FOCUSABLE = 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])' | |
| // The one modal primitive: builds the box, traps the keyboard in it, and | |
| // resolves once through `done`. Enter confirms, Escape cancels, a click on the | |
| // scrim cancels, and focus returns to whatever had it before. | |
| function modal({ title, message, fields = [], confirmLabel, cancelLabel, danger, onConfirm, cancelValue }) { | |
| ensureDialogCss() | |
| return new Promise(resolve => { | |
| const wasFocused = document.activeElement | |
| const scrim = el('div', { class: 'ui-modal-scrim' }) | |
| const box = el('div', { class: 'ui-modal', role: 'dialog', 'aria-modal': 'true' }) | |
| if (title) { | |
| const h = el('div', { class: 'ui-modal-title', id: 'ui-modal-title' }, title) | |
| box.appendChild(h) | |
| box.setAttribute('aria-labelledby', 'ui-modal-title') | |
| } | |
| if (message) box.appendChild(el('div', { class: 'ui-modal-msg' }, message)) | |
| for (const f of fields) box.appendChild(f) | |
| const bar = el('div', { class: 'ui-modal-bar' }) | |
| const confirm = el('button', { class: 'btn small' + (danger ? ' ui-danger' : ' primary'), type: 'button' }, confirmLabel || 'OK') | |
| let settled = false | |
| const close = value => { | |
| if (settled) return | |
| settled = true | |
| document.removeEventListener('keydown', onKey, true) | |
| scrim.remove() | |
| try { | |
| wasFocused?.focus?.({ preventScroll: true }) | |
| } catch {} | |
| resolve(value) | |
| } | |
| const cancel = cancelLabel === null ? null : el('button', { class: 'btn small', type: 'button' }, cancelLabel || 'Cancel') | |
| cancel?.addEventListener('click', () => close(cancelValue)) | |
| confirm.addEventListener('click', () => close(onConfirm ? onConfirm() : true)) | |
| // While this is open the app must not also see the keys: Escape closes | |
| // panels, Enter sends composers, and the editor takes everything else. | |
| const onKey = e => { | |
| if (e.key === 'Escape') { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| close(cancelValue) | |
| return | |
| } | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| close(onConfirm ? onConfirm() : true) | |
| return | |
| } | |
| if (e.key === 'Tab') { | |
| const items = [...box.querySelectorAll(FOCUSABLE)].filter(n => !n.disabled) | |
| if (!items.length) return | |
| const first = items[0] | |
| const last = items[items.length - 1] | |
| const active = document.activeElement | |
| if (e.shiftKey && (active === first || !box.contains(active))) { | |
| e.preventDefault() | |
| last.focus() | |
| } else if (!e.shiftKey && (active === last || !box.contains(active))) { | |
| e.preventDefault() | |
| first.focus() | |
| } | |
| e.stopPropagation() | |
| return | |
| } | |
| if (!box.contains(e.target)) e.stopPropagation() | |
| } | |
| document.addEventListener('keydown', onKey, true) | |
| scrim.addEventListener('mousedown', e => { | |
| if (e.target === scrim) close(cancelValue) | |
| }) | |
| if (cancel) bar.append(cancel, confirm) | |
| else bar.appendChild(confirm) | |
| box.appendChild(bar) | |
| scrim.appendChild(box) | |
| document.body.appendChild(scrim) | |
| // the destructive button is never what the keyboard lands on first | |
| const input = box.querySelector('input') | |
| if (input) input.select() | |
| else if (danger && cancel) cancel.focus() | |
| else confirm.focus() | |
| }) | |
| } | |
| export function uiConfirm({ title, message, confirmLabel, cancelLabel, danger = false } = {}) { | |
| return modal({ title, message, confirmLabel: confirmLabel || (danger ? 'Delete' : 'OK'), cancelLabel, danger, cancelValue: false }) | |
| } | |
| export function uiAlert(message, { title, confirmLabel } = {}) { | |
| const text = String(message ?? '') | |
| // clicking a failing action twice must not stack the same warning twice | |
| const open = [...document.querySelectorAll('.ui-modal-scrim .ui-modal-msg')].some(n => n.textContent === text) | |
| if (open) return Promise.resolve() | |
| return modal({ title, message: text, confirmLabel: confirmLabel || 'OK', cancelLabel: null, cancelValue: undefined }) | |
| } | |
| // The confirm modal with one text field. Resolves to the string, or null when | |
| // it was dismissed — same contract as the prompt() it replaces. | |
| export function uiPrompt({ title, message, label, value = '', placeholder = '', confirmLabel } = {}) { | |
| const field = el('label', { class: 'ui-modal-field' }) | |
| if (label) field.appendChild(document.createTextNode(label)) | |
| const input = el('input', { type: 'text', placeholder }) | |
| input.value = value | |
| field.appendChild(input) | |
| return modal({ | |
| title, | |
| message, | |
| fields: [field], | |
| confirmLabel: confirmLabel || 'OK', | |
| onConfirm: () => input.value.trim(), | |
| cancelValue: null, | |
| }) | |
| } | |
| // Non-blocking errors: nothing to acknowledge, nothing to click through. | |
| export function uiToast(message, { ms = 3200 } = {}) { | |
| ensureDialogCss() | |
| let host = document.getElementById('ui-toast-host') | |
| if (!host) { | |
| host = el('div', { class: 'ui-toast-host', id: 'ui-toast-host', role: 'status', 'aria-live': 'polite' }) | |
| document.body.appendChild(host) | |
| } | |
| const toast = el('div', { class: 'ui-toast' }, String(message ?? '')) | |
| host.appendChild(toast) | |
| requestAnimationFrame(() => toast.classList.add('in')) | |
| setTimeout(() => { | |
| toast.classList.remove('in') | |
| setTimeout(() => toast.remove(), 200) | |
| }, ms) | |
| return toast | |
| } | |
| export function colorFor(name) { | |
| let h = 0 | |
| for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) % 360 | |
| return `hsl(${h}, 32%, 46%)` | |
| } | |
| export function timeAgo(ts) { | |
| if (!ts) return '' | |
| const s = Math.floor((Date.now() - ts) / 1000) | |
| if (s < 60) return 'now' | |
| if (s < 3600) return `${Math.floor(s / 60)}m` | |
| if (s < 86400) return `${Math.floor(s / 3600)}h` | |
| return `${Math.floor(s / 86400)}d` | |
| } | |
| // One-time display of a freshly issued agent key, with copy actions. | |
| export function agentKeyBox({ handle, key, docId }) { | |
| const box = el('div', { class: 'key-box' }) | |
| box.appendChild(el('div', { class: 'key-label' }, `Key for @${handle} — copy it now, it won't be shown again:`)) | |
| box.appendChild(el('code', {}, key)) | |
| const row = el('div', { class: 'row' }) | |
| const copyKey = el('button', { class: 'btn small', type: 'button' }, 'Copy key') | |
| copyKey.addEventListener('click', async () => { | |
| await navigator.clipboard.writeText(key) | |
| copyKey.textContent = 'Copied' | |
| setTimeout(() => (copyKey.textContent = 'Copy key'), 1500) | |
| }) | |
| const copyPrompt = el('button', { class: 'btn small primary', type: 'button' }, 'Copy agent prompt (incl. key)') | |
| copyPrompt.addEventListener('click', async () => { | |
| const text = await fetch(`/api/agent-prompt?handle=${handle}${docId ? `&doc=${docId}` : ''}`).then(r => r.text()) | |
| await navigator.clipboard.writeText(text.replace('<the key>', key)) | |
| copyPrompt.textContent = 'Copied' | |
| setTimeout(() => (copyPrompt.textContent = 'Copy agent prompt (incl. key)'), 1500) | |
| }) | |
| const dismiss = el('button', { class: 'btn small ghost', type: 'button' }, 'Done') | |
| dismiss.addEventListener('click', () => box.remove()) | |
| row.append(copyKey, copyPrompt, dismiss) | |
| box.appendChild(row) | |
| return box | |
| } | |
| export function statusLabel(status) { | |
| return { pending: 'pending', claimed: 'seen', done: 'handled', failed: 'failed' }[status] || status | |
| } | |