| |
|
|
| const ICONS = { |
| 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"/>', |
| back: '<path d="M15 4l-7 7 7 7"/>', |
| 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"/>', |
| 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"/>', |
| 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"/>', |
| resolve: '<circle cx="11" cy="11" r="8"/><path d="M7.5 11.5l2.5 2.5 4.5-5"/>', |
| 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) { |
| const res = await fetch('/api/me').then(r => r.json()) |
| const whoami = document.getElementById('whoami') |
| 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 { |
| |
| |
| |
| 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>') |
| } |
|
|
| 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` |
| } |
|
|
| |
| 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 |
| } |
|
|