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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
}