File size: 6,817 Bytes
b60973d
 
 
b04fa88
e8f61c8
2ad2aff
 
e8f61c8
 
b60973d
 
 
 
 
 
 
 
 
 
 
 
 
45ebf29
 
 
 
b60973d
 
 
 
 
 
 
 
 
99a44ac
 
 
 
 
704a62b
99a44ac
 
 
 
 
 
 
 
 
 
 
93abf36
99a44ac
 
 
 
 
 
 
 
 
 
 
3387889
b60973d
 
3387889
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
// 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"/>',
  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 {
    // 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>')
}

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
}