Spaces:
Running
Running
File size: 10,515 Bytes
b60973d b04fa88 e8f61c8 2ad2aff e8f61c8 f35d3f6 b60973d 8012b6f 2460317 b60973d 45ebf29 b60973d 99a44ac ec6b346 99a44ac 704a62b 99a44ac 93abf36 99a44ac 3387889 b60973d 3387889 99a44ac 8012b6f 2460317 99a44ac b60973d 99a44ac b60973d 99a44ac 53148eb e8f61c8 53148eb b60973d 99a44ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | // Shared client helpers: auth bootstrap, fetch wrapper, DOM utils, SVG icons.
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"/>',
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"/>',
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) {
// 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')
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']
export function wireDocStyle(btn) {
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()
}
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)
}
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
}
|