PageSourceSearch

https://metashot.io/dashboard.js?v=2

js metashot.io collected 2026-09-25 22:16:00 UTC 125,289 bytes, 2,748 lines download raw bytes

1// Dashboard SPA logic
2
3const API = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' 
4  ? 'http://localhost:8788' 
5  : 'https://api.metashot.io'
6let token = localStorage.getItem('og_token') || null
7let user = JSON.parse(localStorage.getItem('og_user') || 'null')
8let keys = []
9let templates = []
10let editingTemplateId = null
11
12// ─── Visual Editor State ──────────────────────────────────────────────────────
13let teMode = 'visual'          // 'visual' | 'code'
14let veBgMode = 'solid'
15let veShape = 'circles'
16let vePreviewTheme = 'current'
17let veShowBar = true
18let veShowStripe = true
19let veIsPublic = false
20let veLogoKey = null           // R2 key (e.g. logos/uid/uuid.png)
21let veLogoUrl = null           // Full URL served via /api/logos/...
22let veLogoDataUri = null       // base64 data URI used for live SVG preview
23let veActiveFontFamily = 'Inter'
24let veCustomFonts = []          // [{family, url, weight}] for non-builtin fonts
25let veFontSearchResults = []
26let veFontSearchHighlight = -1
27let veDataUrl = ''              // Optional JSON endpoint for {{variable}} substitution
28let veActivePreset = 'custom'   // Which preset tile is highlighted
29let veCodeImages = []           // [{key, url}] — uploaded images for code-mode templates
30
31// ─── Auth ────────────────────────────────────────────────────────────────────
32
33async function api(path, opts = {}) {
34  const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }
35  const res = await fetch(`${API}${path}`, { ...opts, headers: { ...headers, ...(opts.headers || {}) } })
36  const data = await res.json().catch(() => ({}))
37  return { ok: res.ok, status: res.status, data }
38}
39
40function hideLoader() {
41  const loader = document.getElementById('dash-loader')
42  if (!loader) return
43  loader.classList.add('fade-out')
44  setTimeout(() => loader.classList.add('hidden'), 260)
45}
46
47function hideAllAuthForms() {
48  ['login-form', 'register-form', 'register-success', 'forgot-form', 'reset-form', 'verify-form'].forEach(id => {
49    document.getElementById(id)?.classList.add('hidden')
50  })
51  document.getElementById('auth-tabs')?.classList.remove('hidden')
52}
53
54function showForgot() { hideAllAuthForms(); document.getElementById('forgot-form').classList.remove('hidden') }
55function showReset() { 
56  hideAllAuthForms(); 
57  document.getElementById('reset-form').classList.remove('hidden');
58  document.getElementById('app').classList.add('hidden');
59  document.getElementById('auth-gate').classList.remove('hidden');
60}
61function showVerify() { 
62  hideAllAuthForms(); 
63  document.getElementById('verify-form').classList.remove('hidden');
64  document.getElementById('app').classList.add('hidden');
65  document.getElementById('auth-gate').classList.remove('hidden');
66}
67
68function showLogin() {
69  hideAllAuthForms()
70  document.getElementById('auth-gate').classList.remove('hidden')
71  document.getElementById('app').classList.add('hidden')
72  document.getElementById('login-form').classList.remove('hidden')
73  document.getElementById('tab-login')?.classList.add('active')
74  document.getElementById('tab-register')?.classList.remove('active')
75  if (window.location.search) window.history.replaceState({}, '', window.location.pathname)
76}
77function showRegister() {
78  hideAllAuthForms()
79  document.getElementById('auth-gate').classList.remove('hidden')
80  document.getElementById('app').classList.add('hidden')
81  document.getElementById('register-form').classList.remove('hidden')
82  document.getElementById('tab-register')?.classList.add('active')
83  document.getElementById('tab-login')?.classList.remove('active')
84  if (window.location.search) window.history.replaceState({}, '', window.location.pathname)
85}
86
87async function login(e) {
88  e.preventDefault()
89  const email = document.getElementById('login-email').value
90  const password = document.getElementById('login-password').value
91  const errEl = document.getElementById('login-error')
92  errEl.classList.add('hidden')
93  const btn = document.getElementById('login-submit')
94  btn.textContent = 'Signing in…'; btn.disabled = true
95
96  const { ok, data } = await api('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
97  btn.textContent = 'Sign in'; btn.disabled = false
98
99  if (!ok) { 
100    if (data.unverified) {
101      document.getElementById('sent-email-display').textContent = email
102      showRegisterSuccess()
103      return
104    }
105    errEl.textContent = data.error || 'Login failed'; 
106    errEl.classList.remove('hidden'); 
107    return 
108  }
109  token = data.token
110  user = data.user
111  localStorage.setItem('og_token', token)
112  localStorage.setItem('og_user', JSON.stringify(user))
113  showApp()
114}
115
116async function register(e) {
117  e.preventDefault()
118  const name = document.getElementById('reg-name').value
119  const email = document.getElementById('reg-email').value
120  const password = document.getElementById('reg-password').value
121  const errEl = document.getElementById('reg-error')
122  errEl.classList.add('hidden')
123  const btn = document.getElementById('reg-submit')
124  btn.textContent = 'Creating account…'; btn.disabled = true
125
126  const { ok, data } = await api('/auth/register', { method: 'POST', body: JSON.stringify({ email, password, ...(name && { name }) }) })
127  btn.textContent = 'Create account'; btn.disabled = false
128
129  if (!ok) { errEl.textContent = data.error || 'Registration failed'; errEl.classList.remove('hidden'); return }
130  
131  // Show success view instead of immediate dashboard access
132  document.getElementById('sent-email-display').textContent = email
133  showRegisterSuccess()
134}
135
136function showToast(msg, type = 'info') {
137  const t = document.createElement('div')
138  t.className = `toast toast-${type}`
139  t.textContent = msg
140  document.body.appendChild(t)
141  setTimeout(() => t.classList.add('show'), 100)
142  setTimeout(() => {
143    t.classList.remove('show')
144    setTimeout(() => { if (t.parentNode) t.remove() }, 300)
145  }, 4000)
146}
147
148async function submitForgotPassword(e) {
149  e.preventDefault()
150  const email = document.getElementById('forgot-email').value
151  const errEl = document.getElementById('forgot-error')
152  const successEl = document.getElementById('forgot-success')
153  const btn = document.getElementById('forgot-submit')
154  errEl.classList.add('hidden'); successEl.classList.add('hidden')
155  btn.textContent = 'Sending…'; btn.disabled = true
156
157  const { ok, data } = await api('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) })
158  btn.textContent = 'Send reset link'; btn.disabled = false
159
160  if (!ok) { errEl.textContent = data.error || 'Failed to send reset link'; errEl.classList.remove('hidden'); return }
161  successEl.textContent = 'If that account exists, a reset link has been sent.'; successEl.classList.remove('hidden')
162}
163
164async function submitResetPassword(e) {
165  e.preventDefault()
166  const password = document.getElementById('reset-password-input').value
167  const params = new URLSearchParams(window.location.search)
168  const tokenUrl = params.get('token')
169  const errEl = document.getElementById('reset-error')
170  const successEl = document.getElementById('reset-success')
171  const btn = document.getElementById('reset-submit')
172  errEl.classList.add('hidden'); successEl.classList.add('hidden')
173  btn.textContent = 'Updating…'; btn.disabled = true
174
175  const { ok, data } = await api('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token: tokenUrl, password }) })
176  btn.textContent = 'Update password'; btn.disabled = false
177
178  if (!ok) { errEl.textContent = data.error || 'Failed to reset password. Link may be expired.'; errEl.classList.remove('hidden'); return }
179  successEl.textContent = 'Password updated successfully! You can now sign in.'; successEl.classList.remove('hidden')
180  document.getElementById('reset-password-input').value = ''
181  setTimeout(showLogin, 3000)
182}
183
184async function verifyEmailAuto(tokenStr) {
185  showVerify()
186  const errEl = document.getElementById('verify-error')
187  const successEl = document.getElementById('verify-success')
188  const loading = document.getElementById('verify-loading')
189  const btn = document.getElementById('verify-login-btn')
190  
191  const { ok, data } = await api('/auth/verify-email', { method: 'POST', body: JSON.stringify({ token: tokenStr }) })
192  loading.classList.add('hidden')
193  
194  if (!ok) { errEl.textContent = data.error || 'Verification failed or link expired.'; errEl.classList.remove('hidden'); btn.classList.remove('hidden'); return }
195  successEl.classList.remove('hidden'); btn.classList.remove('hidden')
196}
197
198function logout() {
199  localStorage.removeItem('og_token')
200  localStorage.removeItem('og_user')
201  token = null; user = null; keys = []
202  document.getElementById('app').classList.add('hidden')
203  document.getElementById('auth-gate').classList.remove('hidden')
204  showLogin()
205}
206
207// ─── App bootstrap ────────────────────────────────────────────────────────────
208
209async function showApp() {
210  document.getElementById('auth-gate').classList.add('hidden')
211  document.getElementById('app').classList.remove('hidden')
212
213  // Check for billing success redirect
214  const params = new URLSearchParams(window.location.search)
215  const isBillingSuccess = params.get('billing') === 'success'
216
217  if (!user || isBillingSuccess) {
218    const { data } = await api('/auth/me')
219    user = data
220    localStorage.setItem('og_user', JSON.stringify(data))
221    if (isBillingSuccess) {
222      showToast('Welcome to Pro! Your subscription is active.', 'success')
223      // Clear URL params
224      window.history.replaceState({}, '', '/dashboard')
225    }
226  }
227
228  const isBillingCancel = params.get('billing') === 'cancel'
229  if (isBillingCancel) {
230    showToast('Subscription not changed.', 'info')
231    window.history.replaceState({}, '', '/dashboard')
232  }
233  
234  let displayName = user.email
235  if (user.name) {
236    if (user.name.length <= 15) {
237      displayName = user.name
238    } else {
239      const parts = user.name.trim().split(' ')
240      displayName = parts.length > 1 ? parts[parts.length - 1] : user.name.slice(-15)
241    }
242  }
243  document.getElementById('user-email-display').textContent = displayName
244  if (user.is_admin) {
245    document.getElementById('nav-admin')?.classList.remove('hidden')
246  }
247  await Promise.all([loadKeys(), loadTemplates()])
248  renderPlanBox()
249  document.getElementById('plan-box').style.opacity = '1'
250  loadUsageStats()
251  initEndpointTabs()
252  updateDashPreview()
253
254  // Verification banner
255  const banner = document.getElementById('verify-banner')
256  if (user && !user.is_verified) {
257    banner?.classList.remove('hidden')
258  } else {
259    banner?.classList.add('hidden')
260  }
261
262  // Restore section from hash or default to keys
263  const hash = window.location.hash.replace('#', '')
264  const validSections = ['keys', 'templates', 'images', 'usage', 'admin']
265  showSection(validSections.includes(hash) ? hash : 'keys')
266
267  // Dismiss loader only after everything is rendered — prevents any flash
268  hideLoader()
269
270  // First-time user: auto-create a default key and show it immediately
271  if (keys.length === 0) {
272    await autoCreateFirstKey()
273  }
274}
275
276async function autoCreateFirstKey() {
277  const { ok, data } = await api('/api/keys', {
278    method: 'POST',
279    body: JSON.stringify({ name: 'Default' })
280  })
281  if (!ok) return
282
283  await loadKeys()
284  renderPlanBox()
285  openQuickstartWelcome(data.key)
286}
287
288function openQuickstartWelcome(apiKey) {
289  quickstartLiveKey = apiKey || null
290  // Show welcome banner and "Go to my keys" close button
291  document.getElementById('qs-welcome-banner')?.classList.remove('hidden')
292  const closeBtn = document.getElementById('qs-close-btn')
293  if (closeBtn) closeBtn.style.display = ''
294  const modal = document.getElementById('quickstart-modal')
295  if (modal) modal.classList.remove('hidden')
296  updateQuickstartCard()
297}
298
299// Add hash change listener for navigation
300window.onhashchange = () => {
301  const hash = window.location.hash.replace('#', '')
302  const validSections = ['keys', 'templates', 'images', 'usage', 'admin']
303  if (validSections.includes(hash)) {
304    // Only update if it's different to avoid loops
305    const currentActive = document.querySelector('.dash-navlink.active')?.id.replace('nav-', '')
306    if (currentActive !== hash) showSection(hash)
307  }
308}
309
310function renderPlanBox() {
311  const plan = user?.plan || 'free'
312  const isPro = plan === 'pro' || plan === 'scale'
313  const freeLimit = user?.free_plan_limit || 1000
314  const proLimit = user?.pro_plan_limit || 50000
315  const scaleLimit = 250000
316  const totalLimit = plan === 'scale' ? scaleLimit : isPro ? proLimit : freeLimit
317  const totalUsed = keys.reduce((s, k) => s + k.requests_used, 0)
318  const pct = Math.min(100, Math.round((totalUsed / totalLimit) * 100))
319
320  const planLabel = plan === 'scale' ? 'Scale' : plan === 'pro' ? 'Pro' : 'Free'
321  let planText = planLabel + ' Plan'
322  if (isPro && user?.cancel_at_period_end) {
323    const d = user.plan_expires_at ? new Date(user.plan_expires_at).toLocaleDateString() : ''
324    planText = `${planLabel} (Expiring ${d})`.replace(' ()', '')
325  }
326  document.getElementById('plan-name').textContent = planText
327  document.getElementById('plan-bar').style.width = `${pct}%`
328  
329  let usageText = `${totalUsed.toLocaleString()} / ${totalLimit.toLocaleString()} requests this month`
330  if (isPro && user?.cancel_at_period_end && user?.plan_expires_at) {
331    const d = new Date(user.plan_expires_at)
332    usageText += ` • Expires ${d.toLocaleDateString()}`
333  }
334  document.getElementById('plan-usage').textContent = usageText
335  
336  // Toggle buttons
337  const upgradeBtn = document.getElementById('upgrade-btn')
338  const manageBtn = document.getElementById('manage-billing-btn')
339  const proBadge = document.getElementById('user-plan-badge')
340  const planBox = document.getElementById('plan-box')
341
342  if (isPro) {
343    upgradeBtn?.classList.add('hidden')
344    manageBtn?.classList.remove('hidden')
345    proBadge?.classList.remove('hidden')
346    planBox?.classList.add('premium')
347  } else {
348    upgradeBtn?.classList.remove('hidden')
349    manageBtn?.classList.add('hidden')
350    proBadge?.classList.add('hidden')
351    planBox?.classList.remove('premium')
352  }
353
354  // Gate Pro-only features in the UI
355  const signingCheckbox = document.getElementById('check-enable-signing')
356  const signingProBadge = document.getElementById('signing-pro-badge')
357  if (signingCheckbox) {
358    signingCheckbox.disabled = !isPro
359    signingCheckbox.style.opacity = isPro ? '' : '0.4'
360    signingCheckbox.style.cursor = isPro ? 'pointer' : 'not-allowed'
361  }
362  if (signingProBadge) signingProBadge.classList.toggle('hidden', isPro)
363}
364
365async function upgradeToPro() {
366  const btn = document.getElementById('upgrade-btn')
367  if (btn) { btn.textContent = 'Preparing…'; btn.disabled = true }
368
369  const { ok, data } = await api('/api/billing/checkout', { method: 'POST' })
370  if (ok && data.url) {
371    window.location.href = data.url
372  } else {
373    if (btn) { btn.textContent = 'Upgrade to Pro'; btn.disabled = false }
374    alert(data.error || 'Failed to start checkout. Please try again.')
375  }
376}
377
378async function manageSubscription() {
379  const btn = document.getElementById('manage-billing-btn')
380  const originalText = btn.textContent
381  btn.textContent = 'Opening portal...'; btn.disabled = true
382  
383  const { ok, data } = await api('/api/billing/portal')
384  if (ok && data.url) {
385    window.location.href = data.url
386  } else {
387    btn.textContent = originalText; btn.disabled = false
388    alert(data.error || 'Failed to open billing portal.')
389  }
390}
391
392// ─── Sections ─────────────────────────────────────────────────────────────────
393
394function showSection(name) {
395  document.querySelectorAll('.dash-section').forEach(s => s.classList.add('hidden'))
396  document.querySelectorAll('.dash-navlink').forEach(l => l.classList.remove('active'))
397  
398  const section = document.getElementById(`section-${name}`)
399  const nav = document.getElementById(`nav-${name}`)
400  
401  if (section) section.classList.remove('hidden')
402  if (nav) nav.classList.add('active')
403
404  // Update hash without triggering hashchange again
405  if (window.location.hash !== `#${name}`) {
406    window.location.hash = name
407  }
408
409  if (name === 'usage') loadUsageStats()
410  if (name === 'templates') loadTemplates()
411  if (name === 'images') loadImages(1)
412  if (name === 'admin') loadAdminData()
413}
414
415// ─── Keys ─────────────────────────────────────────────────────────────────────
416
417let keysLimit = 10;
418
419async function loadKeys() {
420  const { data } = await api('/api/keys')
421  keys = Array.isArray(data) ? data : []
422  keysLimit = 10; // Reset pagination on load
423  renderKeys()
424}
425
426let showRevoked = false;
427
428function renderKeys() {
429  const el = document.getElementById('keys-list')
430  if (!keys.length) { el.innerHTML = '<div class="loading">No API keys yet. Create one to get started.</div>'; return }
431  
432  const activeKeys = keys.filter(k => k.is_active)
433  const revokedKeys = keys.filter(k => !k.is_active)
434
435  const visibleActive = activeKeys.slice(0, keysLimit)
436  let html = visibleActive.map(k => renderKeyCard(k)).join('')
437  
438  if (activeKeys.length > keysLimit) {
439    html += `
440      <div style="text-align: center; margin-top: 24px;">
441        <button class="btn-ghost" onclick="loadMoreKeys()" style="font-size: 0.9rem; padding: 10px 24px; border: 1px solid var(--border);">Load More Keys</button>
442      </div>
443    `
444  }
445  
446  if (revokedKeys.length > 0) {
447    html += `
448      <div style="margin-top: 60px; padding-top: 32px; border-top: 1px dashed var(--border);">
449        <button class="btn-ghost btn-sm" onclick="toggleRevoked()" style="margin: 0 auto; display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 0.85rem;">
450          ${showRevoked ? 'Hide' : 'Show'} ${revokedKeys.length} Revoked Key${revokedKeys.length > 1 ? 's' : ''}
451          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: ${showRevoked ? 'rotate(180deg)' : 'none'}; transition: transform 0.2s;">
452            <polyline points="6 9 12 15 18 9"></polyline>
453          </svg>
454        </button>
455        <div id="revoked-keys-wrap" class="${showRevoked ? '' : 'hidden'}" style="margin-top: 24px; opacity: 0.6;">
456          ${revokedKeys.map(k => renderKeyCard(k)).join('')}
457        </div>
458      </div>
459    `
460  }
461  
462  el.innerHTML = html
463}
464
465// ─── Quickstart card ──────────────────────────────────────────────────────────
466
467let quickstartTheme = 'gradient'
468let quickstartLiveKey = null  // set to real key on first-login welcome mode
469
470function buildQuickstartUrl() {
471  const title    = (document.getElementById('qs-title')?.value    || 'Hello MetaShot').trim()
472  const subtitle = (document.getElementById('qs-subtitle')?.value || '').trim()
473  const author   = (document.getElementById('qs-author')?.value   || '').trim()
474  const key = quickstartLiveKey || 'YOUR_KEY'
475  const params = new URLSearchParams({ key, title })
476  if (subtitle) params.set('subtitle', subtitle)
477  if (author)   params.set('author', author)
478  params.set('theme', quickstartTheme)
479  return `https://api.metashot.io/og?${params.toString()}`
480}
481
482function updateQuickstartCard() {
483  const title    = (document.getElementById('qs-title')?.value    || 'Hello MetaShot').trim()
484  const subtitle = (document.getElementById('qs-subtitle')?.value || '').trim()
485  const author   = (document.getElementById('qs-author')?.value   || '').trim()
486
487  const urlEl = document.getElementById('quickstart-url')
488  if (urlEl) urlEl.textContent = buildQuickstartUrl()
489
490  const preview = document.getElementById('qs-preview')
491  if (preview) {
492    const svg = renderSvg({ title, subtitle, author, theme: quickstartTheme })
493    preview.innerHTML = svg
494    const svgEl = preview.querySelector('svg')
495    if (svgEl) { svgEl.style.width = '100%'; svgEl.style.height = '100%' }
496  }
497}
498
499function toggleQuickstart() {
500  const modal = document.getElementById('quickstart-modal')
501  if (!modal) return
502  if (modal.classList.contains('hidden')) {
503    modal.classList.remove('hidden')
504    updateQuickstartCard()
505  } else {
506    modal.classList.add('hidden')
507  }
508}
509
510function setQuickstartTheme(theme) {
511  quickstartTheme = theme
512  document.querySelectorAll('.qs-theme-pill').forEach(p => {
513    p.classList.toggle('active', p.dataset.theme === theme)
514  })
515  updateQuickstartCard()
516}
517
518function copyQuickstartUrl() {
519  const msg = quickstartLiveKey ? 'URL copied — paste it into your <meta> tag' : 'URL copied — replace YOUR_KEY with your actual key'
520  navigator.clipboard.writeText(buildQuickstartUrl()).then(() => showToast(msg, 'success'))
521}
522
523function dismissQuickstart() {
524  document.getElementById('quickstart-modal')?.classList.add('hidden')
525  // Reset welcome mode state for next open
526  quickstartLiveKey = null
527  document.getElementById('qs-welcome-banner')?.classList.add('hidden')
528  const closeBtn = document.getElementById('qs-close-btn')
529  if (closeBtn) closeBtn.style.display = 'none'
530}
531
532// ─── Image preview lightbox ───────────────────────────────────────────────────
533
534function closeImagePreview() {
535  const modal = document.getElementById('image-preview-modal')
536  if (!modal) return
537  modal.classList.add('hidden')
538  const img = document.getElementById('image-preview-img')
539  if (img && img.src.startsWith('blob:')) URL.revokeObjectURL(img.src)
540  if (img) img.src = ''
541}
542
543async function revealKey(id, btn) {
544  const originalHtml = btn.innerHTML
545  btn.innerHTML = '<svg class="spinner-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="width:12px; height:12px; animation: rotate 1s linear infinite;"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path></svg>'
546  btn.disabled = true
547
548  const { ok, data } = await api(`/api/keys/${id}/reveal`)
549  if (!ok) {
550    alert(data.error || 'Failed to reveal key')
551    btn.innerHTML = originalHtml
552    btn.disabled = false
553    return
554  }
555
556  const wrap = document.getElementById(`key-display-${id}`)
557  if (wrap) {
558    wrap.innerHTML = `
559      <div style="display: flex; align-items: center; gap: 12px; height: 24px;">
560        <span class="muted" style="font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; width: 60px;">API Key:</span>
561        <code class="key-text-revealed" style="background: rgba(99,102,241,0.1); padding: 2px 8px; border-radius: 4px; border: 1px solid rgba(99,102,241,0.2); word-break: break-all; font-size: 0.85rem;">
562          ${esc(data.key)}
563        </code>
564        <button class="btn-ghost btn-xs" onclick="copyText('${esc(data.key)}', this)" style="padding: 2px; flex-shrink: 0; opacity: 0.7;">
565          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
566        </button>
567      </div>
568    `
569  }
570}
571
572function loadMoreKeys() {
573  keysLimit += 10;
574  renderKeys();
575}
576
577function toggleRevoked() {
578  showRevoked = !showRevoked;
579  renderKeys();
580}
581
582function renderKeyCard(k) {
583  return `
584    <div class="key-card ${k.is_active ? '' : 'key-inactive'}" id="key-${k.id}" style="display: block; margin-bottom: 20px;">
585      <div style="display: flex; justify-content: space-between; align-items: flex-start;">
586        <div style="flex: 1;">
587          <div class="key-name" style="margin-bottom: 12px; font-size: 1.1rem;">${esc(k.name)}</div>
588          <div class="key-meta" style="display: flex; flex-direction: column; gap: 8px;">
589            <div id="key-display-${k.id}" style="display: flex; align-items: center; gap: 12px; height: 24px;">
590              <span class="muted" style="font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; width: 60px;">API Key:</span>
591              <code class="key-prefix" style="font-size: 0.85rem; padding: 2px 8px;">
592                ${esc(k.key_prefix)}…
593                ${k.has_reveal ? `
594                  <button class="reveal-btn" onclick="revealKey('${k.id}', this)" title="Reveal API Key" style="margin-left: 4px;">
595                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
596                  </button>
597                ` : ''}
598              </code>
599            </div>
600            <div style="display: flex; align-items: center; gap: 12px; height: 24px;">
601              <span class="muted" style="font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; width: 60px;">Secret:</span>
602              ${k.signing_secret
603                ? `<code style="background: rgba(0,0,0,0.3); padding: 2px 8px; border-radius: 4px; color: #38bdf8; font-family: var(--font-mono); font-size: 0.85rem; border: 1px solid rgba(56, 189, 248, 0.1);">${k.signing_secret}</code>
604                   <button class="btn-ghost btn-xs" onclick="copyText('${k.signing_secret}', this)" style="padding: 2px; opacity: 0.7;" title="Copy Signing Secret">
605                     <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
606                   </button>`
607                : `<span style="font-size:0.75rem; color:var(--muted); cursor:pointer;" onclick="upgradeToPro()">⬆ <span style="color:#818cf8; font-weight:600;">Pro feature</span> — upgrade to enable signed URLs</span>`
608              }
609            </div>
610          </div>
611        </div>
612        <div class="key-actions">
613          ${k.is_active ? `
614            <button class="btn-ghost danger btn-sm" onclick="revokeKey('${k.id}')" title="Revoke API Key" style="padding: 6px; background: rgba(239, 68, 68, 0.05); border: 1px solid rgba(239, 68, 68, 0.1); border-radius: 6px;">
615              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
616            </button>
617          ` : '<span class="revoked-badge">Revoked</span>'}
618        </div>
619      </div>
620      <div style="margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: 0.75rem; color: var(--muted);">
621        <div style="display: flex; gap: 12px;">
622          <span class="key-usage-badge" style="font-weight: 600; color: var(--success); opacity: 0.8;">${k.requests_used.toLocaleString()} total used</span>
623          <span style="background: rgba(255,255,255,0.03); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--border);">${k.allowed_domains ? esc(k.allowed_domains) : 'Any domain'}</span>
624        </div>
625        <span style="opacity: 0.6;">${k.last_used_at ? `Used ${timeAgo(k.last_used_at)}` : 'Never used'}</span>
626      </div>
627    </div>
628  `
629}
630
631
632function openCreateKey() {
633  const isPro = user && ['pro', 'scale'].includes(user.plan)
634  const domainsInput = document.getElementById('key-domains-input')
635  const domainsNotice = document.getElementById('key-domains-pro-notice')
636  const domainsBadge = document.getElementById('key-domains-pro-badge')
637  if (domainsInput) {
638    domainsInput.disabled = !isPro
639    domainsInput.style.opacity = isPro ? '' : '0.4'
640    domainsInput.style.cursor = isPro ? '' : 'not-allowed'
641  }
642  if (domainsNotice) domainsNotice.classList.toggle('hidden', isPro)
643  if (domainsBadge) domainsBadge.style.display = isPro ? 'none' : ''
644  document.getElementById('create-key-modal').classList.remove('hidden')
645}
646function closeCreateKey() {
647  document.getElementById('create-key-modal').classList.add('hidden')
648  document.getElementById('key-name-input').value = ''
649  document.getElementById('key-domains-input').value = ''
650}
651
652async function createKey() {
653  const isPro = user && ['pro', 'scale'].includes(user.plan)
654  const name = document.getElementById('key-name-input').value.trim()
655  const allowed_domains = isPro ? (document.getElementById('key-domains-input').value.trim() || null) : null
656  const errEl = document.getElementById('create-key-error')
657  errEl.classList.add('hidden')
658  if (!name) { errEl.textContent = 'Please enter a key name'; errEl.classList.remove('hidden'); return }
659
660  const btn = document.getElementById('create-key-submit')
661  btn.textContent = 'Creating…'; btn.disabled = true
662  const { ok, data } = await api('/api/keys', {
663    method: 'POST',
664    body: JSON.stringify({ name, allowed_domains })
665  })
666  btn.textContent = 'Create key'; btn.disabled = false
667
668  if (!ok) { errEl.textContent = data.error || 'Failed to create key'; errEl.classList.remove('hidden'); return }
669
670  closeCreateKey()
671  document.getElementById('revealed-key').textContent = data.key
672
673  // Signing secret only shown for Pro+ users
674  const secretField = document.getElementById('revealed-secret-field')
675  const secretBox = document.getElementById('revealed-secret-box')
676  const secretNotice = document.getElementById('revealed-secret-pro-notice')
677  if (isPro && data.signing_secret) {
678    document.getElementById('revealed-secret').textContent = data.signing_secret
679    if (secretBox) secretBox.style.display = ''
680    if (secretNotice) secretNotice.classList.add('hidden')
681  } else {
682    if (secretBox) secretBox.style.display = 'none'
683    if (secretNotice) secretNotice.classList.remove('hidden')
684  }
685
686  showRevealModal()
687  await loadKeys()
688  renderPlanBox()
689}
690
691function showRevealModal() {
692  document.getElementById('key-reveal-modal').classList.remove('hidden')
693}
694
695function copyValue(id) {
696  const val = document.getElementById(id).textContent
697  copyText(val, window.event?.currentTarget)
698}
699
700function copyText(val, btn) {
701  if (!val || val === 'N/A') return
702  navigator.clipboard.writeText(val).then(() => {
703    btn = btn || window.event?.currentTarget
704    if (btn) {
705      const orig = btn.innerHTML
706      btn.innerHTML = 'Copied!'
707      btn.style.color = 'var(--success)'
708      setTimeout(() => { btn.innerHTML = orig; btn.style.color = '' }, 2000)
709    }
710  })
711}
712
713function closeReveal() {
714  document.getElementById('key-reveal-modal').classList.add('hidden')
715}
716
717
718// ─── Custom Confirm Modal ───────────────────────────────────────────────────
719let currentConfirmResolve = null;
720
721function customConfirm(title, message, btnText, isDanger) {
722  return new Promise((resolve) => {
723    document.getElementById('confirm-title').textContent = title;
724    document.getElementById('confirm-message').textContent = message;
725    
726    const btn = document.getElementById('confirm-btn');
727    btn.textContent = btnText;
728    if (isDanger) {
729      btn.style.background = 'var(--danger)';
730      btn.style.borderColor = 'var(--danger)';
731    } else {
732      btn.style.background = '';
733      btn.style.borderColor = '';
734    }
735    
736    currentConfirmResolve = resolve;
737    document.getElementById('confirm-modal').classList.remove('hidden');
738  });
739}
740
741function closeConfirmModal(result = false) {
742  document.getElementById('confirm-modal').classList.add('hidden');
743  if (currentConfirmResolve) {
744    currentConfirmResolve(result);
745    currentConfirmResolve = null;
746  }
747}
748
749async function revokeKey(id) {
750  const confirmed = await customConfirm('Revoke API Key', 'Are you sure you want to revoke this API key? Applications currently using it will immediately lose access.', 'Revoke Key', true);
751  if (!confirmed) return;
752  const { ok } = await api(`/api/keys/${id}`, { method: 'DELETE' })
753  if (ok) { await loadKeys(); renderPlanBox() }
754}
755
756// ─── Templates ───────────────────────────────────────────────────────────────
757
758async function loadTemplates() {
759  const { ok, data } = await api('/api/templates')
760  if (ok) {
761    templates = data
762    renderTemplates()
763    populateThemeSelector()
764  }
765}
766
767function renderTemplates() {
768  const list = document.getElementById('templates-list')
769  if (!list) return
770  if (!templates.length) {
771    list.innerHTML = `<div class="empty-state">No custom templates yet.<br><span class="muted" style="font-size:0.9rem">Create one to use custom designs.</span></div>`
772    return
773  }
774  
775  list.innerHTML = templates.map(t => `
776    <div class="key-card" style="display: block;">
777      <div style="display: flex; justify-content: space-between; align-items: flex-start;">
778        <div style="flex: 1;">
779          <h4 style="margin:0; font-size:1.1rem; color: white;">${esc(t.name)}</h4>
780          <div style="margin-top: 8px; display: flex; align-items: center; gap: 8px;">
781            <code style="background: rgba(0,0,0,0.3); padding: 3px 6px; border-radius: 4px; color: var(--accent); font-size: 0.8rem; font-family: var(--font-mono);">custom_${esc(t.id)}</code>
782            <button class="btn-ghost btn-xs" onclick="copyText('custom_${t.id}', this)" style="padding: 3px 6px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 4px;" title="Copy theme value">
783              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
784            </button>
785          </div>
786        </div>
787        <div style="display: flex; gap: 8px;">
788          <button class="btn-ghost" onclick="openEditTemplate('${t.id}')" title="Edit template" style="padding: 8px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px;">
789            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
790          </button>
791          <button class="btn-ghost danger" onclick="deleteTemplate('${t.id}')" title="Delete template" style="padding: 8px; background: rgba(239, 68, 68, 0.05); border: 1px solid rgba(239, 68, 68, 0.1); border-radius: 8px;">
792            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
793          </button>
794        </div>
795      </div>
796      <div style="margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);">
797        <div style="font-size:0.65rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;">Usage URL</div>
798        <div style="display:flex;align-items:center;gap:6px;">
799          <code id="tcard-url-${t.id}" style="flex:1;font-size:0.62rem;font-family:var(--font-mono);color:var(--muted);background:rgba(0,0,0,0.3);padding:5px 8px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">https://api.metashot.io/og?key=msk_...&amp;theme=custom_${t.id}&amp;title=Hello+World${t.config?.data_url ? '&amp;data_url=' + encodeURIComponent(t.config.data_url) : ''}</code>
800          <button class="btn-ghost btn-xs" onclick="copyText(document.getElementById('tcard-url-${t.id}').textContent,this)" style="padding:4px 6px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:4px;flex-shrink:0;" title="Copy URL">
801            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
802          </button>
803        </div>
804        <div style="margin-top:5px;font-size:0.62rem;color:var(--muted);line-height:1.6;">
805          Optional params: <code style="color:var(--accent);font-family:var(--font-mono);">&amp;subtitle=...</code>
806          <code style="color:var(--accent);font-family:var(--font-mono);">&amp;author=...</code>
807          <code style="color:var(--accent);font-family:var(--font-mono);">&amp;data_url=https://...</code>
808          <code style="color:var(--accent);font-family:var(--font-mono);">&amp;s=&lt;hmac&gt;</code>
809        </div>
810        <div style="margin-top:8px;font-size:0.7rem;color:var(--muted);">Updated ${timeAgo(t.updated_at || t.created_at)}</div>
811      </div>
812    </div>
813  `).join('')
814}
815
816
817function populateThemeSelector() {
818  const select = document.getElementById('dash-theme')
819  if (!select) return
820
821  // Remove any existing custom optgroup entirely before rebuilding
822  select.querySelector('#custom-theme-group')?.remove()
823
824  // Only show templates owned by the current user
825  const ownTemplates = templates.filter(t => t.user_id === user?.id)
826  if (ownTemplates.length === 0) return
827
828  const optgroup = document.createElement('optgroup')
829  optgroup.id = 'custom-theme-group'
830  optgroup.label = 'Custom Templates'
831  ownTemplates.forEach(t => {
832    const opt = document.createElement('option')
833    opt.value = `custom_${t.id}`
834    opt.textContent = t.name
835    optgroup.appendChild(opt)
836  })
837  select.appendChild(optgroup)
838}
839
840
841
842// ─── Template Editor ──────────────────────────────────────────────────────────
843
844function switchEditorMode(mode) {
845  teMode = mode
846  document.getElementById('te-mode-visual').classList.toggle('active', mode === 'visual')
847  document.getElementById('te-mode-code').classList.toggle('active', mode === 'code')
848  document.getElementById('te-visual-pane').classList.toggle('hidden', mode !== 'visual')
849  document.getElementById('te-code-pane').classList.toggle('hidden', mode !== 'code')
850  // Hide preset pills in code mode — user controls the SVG directly
851  const pillsEl = document.querySelector('.te-pills')
852  if (pillsEl) pillsEl.style.display = mode === 'code' ? 'none' : ''
853  // Sync data URL state into the code pane input
854  const duEl = document.getElementById('code-data-url')
855  if (duEl) duEl.value = veDataUrl
856  if (mode === 'visual') renderVisual()
857  else updateTemplatePreview()
858}
859window.switchEditorMode = switchEditorMode
860
861function openCreateTemplate() {
862  editingTemplateId = null
863  veIsPublic = false
864  veLogoKey = null
865  veLogoUrl = null
866
867  document.title = 'New Template — MetaShot.io'
868  document.getElementById('template-name-input').value = ''
869  document.getElementById('template-editor-error').classList.add('hidden')
870  document.getElementById('sw-te-public').classList.remove('on')
871
872  veResetVisualDefaults()
873  document.getElementById('template-code-input').value = ''
874  ;['code-default-title','code-default-subtitle','code-default-author','code-data-url'].forEach(id => {
875    const el = document.getElementById(id); if (el) el.value = ''
876  })
877  veDataUrl = ''
878  veCodeImages = []
879  renderCodeImageList()
880  brandFonts = []
881  renderBrandFontsList()
882  const fontInput = document.getElementById('font-search-input')
883  if (fontInput) fontInput.value = ''
884
885  switchEditorMode('visual')
886  vePopulateSignKeySelect()
887  document.getElementById('templates-list-view').classList.add('hidden')
888  document.getElementById('template-editor-view').classList.remove('hidden')
889}
890window.openCreateTemplate = openCreateTemplate
891
892function openEditTemplate(id) {
893  const t = templates.find(x => x.id === id)
894  if (!t) return
895  editingTemplateId = t.id
896  const cfg = t.config || {}
897
898  document.title = 'Edit Template — MetaShot.io'
899  document.getElementById('template-name-input').value = t.name
900  document.getElementById('template-editor-error').classList.add('hidden')
901
902  veIsPublic = !!t.is_public
903  document.getElementById('sw-te-public').classList.toggle('on', veIsPublic)
904
905  const isVisual = cfg.version === 1 || cfg.mode === 'visual'
906  if (isVisual) {
907    veRestoreVisualConfig(cfg)
908    brandFonts = []
909    renderBrandFontsList()
910    switchEditorMode('visual')
911  } else {
912    veResetVisualDefaults()
913    document.getElementById('template-code-input').value = cfg.source || ''
914    const cset = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || '' }
915    cset('code-default-title',    cfg.defaultTitle)
916    cset('code-default-subtitle', cfg.defaultSubtitle)
917    cset('code-default-author',   cfg.defaultAuthor)
918    cset('code-data-url',         cfg.data_url)
919    veDataUrl = cfg.data_url || ''
920    veCodeImages = Array.isArray(cfg.images) ? cfg.images : []
921    renderCodeImageList()
922    brandFonts = Array.isArray(cfg.fonts) ? cfg.fonts.map(f => ({ family: f.family || '', url: f.url || '', weight: f.weight })) : []
923    renderBrandFontsList()
924    const fontInput = document.getElementById('font-search-input')
925    if (fontInput) fontInput.value = ''
926    switchEditorMode('code')
927  }
928
929  vePopulateSignKeySelect()
930  document.getElementById('templates-list-view').classList.add('hidden')
931  document.getElementById('template-editor-view').classList.remove('hidden')
932}
933window.openEditTemplate = openEditTemplate
934
935function closeTemplateEditor() {
936  document.getElementById('template-editor-view').classList.add('hidden')
937  document.getElementById('templates-list-view').classList.remove('hidden')
938}
939
940// ─── Visual Editor Helpers ────────────────────────────────────────────────────
941
942function veToggleSection(btn) {
943  const isOpen = btn.classList.contains('open')
944  if (btn.closest('#te-visual-pane')) {
945    // Accordion in visual pane — close all, open the clicked one
946    document.querySelectorAll('#te-visual-pane .ve-section-toggle').forEach(t => {
947      t.classList.remove('open')
948      t.nextElementSibling.classList.remove('open')
949    })
950    if (!isOpen) {
951      btn.classList.add('open')
952      btn.nextElementSibling.classList.add('open')
953    }
954  } else {
955    // Independent toggle in code pane
956    btn.classList.toggle('open', !isOpen)
957    btn.nextElementSibling.classList.toggle('open', !isOpen)
958  }
959}
960window.veToggleSection = veToggleSection
961
962function veSyncColor(cId, hId) {
963  document.getElementById(hId).value = document.getElementById(cId).value
964}
965window.veSyncColor = veSyncColor
966
967function veSyncHex(cId, hId) {
968  const v = document.getElementById(hId).value
969  if (/^#[0-9a-fA-F]{6}$/.test(v)) document.getElementById(cId).value = v
970}
971window.veSyncHex = veSyncHex
972
973// ─── Theme presets ────────────────────────────────────────────────────────────
974
975const VE_PRESETS = {
976  gradient: { bgMode: 'gradient', gradFrom: '#0f0c29', gradTo: '#24243e', fg: '#ffffff', accent: '#a78bfa' },
977  dark:     { bgMode: 'solid',    bg: '#0f172a',                           fg: '#f1f5f9', accent: '#38bdf8' },
978  light:    { bgMode: 'solid',    bg: '#ffffff',                           fg: '#0f172a', accent: '#6366f1' },
979  code:     { bgMode: 'solid',    bg: '#020817',                           fg: '#e2e8f0', accent: '#22d3ee' },
980  article:  { bgMode: 'solid',    bg: '#fafafa',                           fg: '#111827', accent: '#f59e0b' },
981  profile:  { bgMode: 'solid',    bg: '#ffffff',                           fg: '#0f172a', accent: '#22c55e' },
982  minimal:  { bgMode: 'solid',    bg: '#fafafa',                           fg: '#111111', accent: '#111111' },
983}
984
985function veApplyPreset(name, el) {
986  const preset = VE_PRESETS[name]
987  const set = (id, val) => { const e = document.getElementById(id); if (e) e.value = val }
988
989  if (preset) {
990    const m = preset.bgMode
991    veBgMode = m
992    // Sync bg mode tabs
993    document.querySelectorAll('#ve-bg-tabs .ve-tab').forEach(t =>
994      t.classList.toggle('active', t.textContent.trim().toLowerCase() === m))
995    document.getElementById('ve-bg-solid').style.display     = m === 'solid'    ? '' : 'none'
996    document.getElementById('ve-bg-gradient').style.display  = m === 'gradient' ? '' : 'none'
997
998    if (preset.bg)       { set('ve-bg',     preset.bg);       set('ve-bg-hex',     preset.bg) }
999    if (preset.gradFrom) { set('ve-g1',     preset.gradFrom); set('ve-g1-hex',     preset.gradFrom) }
1000    if (preset.gradTo)   { set('ve-g2',     preset.gradTo);   set('ve-g2-hex',     preset.gradTo) }
1001    if (preset.fg)       { set('ve-fg',     preset.fg);       set('ve-fg-hex',     preset.fg) }
1002    if (preset.accent)   { set('ve-accent', preset.accent);   set('ve-accent-hex', preset.accent) }
1003
1004    vePreviewTheme = 'current'
1005  }
1006
1007  veActivePreset = name
1008  // Deactivate all pills (Current + presets), then highlight the selected one
1009  document.querySelectorAll('.te-pill').forEach(p => p.classList.remove('active'))
1010  if (el) el.classList.add('active')
1011
1012  renderVisual()
1013}
1014window.veApplyPreset = veApplyPreset
1015
1016// Called on every manual color change — deselects any preset tile and resets preview to 'current'
1017function veMarkCustom() {
1018  veActivePreset = 'custom'
1019  vePreviewTheme = 'current'
1020  document.querySelectorAll('.te-pill').forEach(p => p.classList.remove('active'))
1021  document.getElementById('ve-preset-custom')?.classList.add('active')
1022}
1023window.veMarkCustom = veMarkCustom
1024
1025function veSetBgMode(mode, el) {
1026  veBgMode = mode
1027  document.querySelectorAll('#ve-bg-tabs .ve-tab').forEach(t => t.classList.remove('active'))
1028  el.classList.add('active')
1029  document.getElementById('ve-bg-solid').style.display    = mode === 'solid'    ? '' : 'none'
1030  document.getElementById('ve-bg-gradient').style.display = mode === 'gradient' ? '' : 'none'
1031  veMarkCustom()
1032  renderVisual()
1033}
1034window.veSetBgMode = veSetBgMode
1035
1036function veSetShape(shape, el) {
1037  veShape = shape
1038  document.querySelectorAll('.ve-shape-btn').forEach(b => b.classList.remove('active'))
1039  el.classList.add('active')
1040  renderVisual()
1041}
1042window.veSetShape = veSetShape
1043
1044function veSetPreviewTheme(theme, el) {
1045  // Clicking a non-Current pill applies that preset to the editor and resets to 'current'
1046  // so the preview shows the actual design, not a temporary overlay.
1047  if (theme !== 'current') {
1048    veApplyPreset(theme, document.getElementById(`ve-preset-${theme}`))
1049    // veApplyPreset already resets the pill to 'current' and rerenders — done.
1050    return
1051  }
1052  vePreviewTheme = 'current'
1053  document.querySelectorAll('.te-pill').forEach(p => p.classList.remove('active'))
1054  document.getElementById('ve-pill-current')?.classList.add('active')
1055  if (teMode === 'visual') renderVisual()
1056}
1057window.veSetPreviewTheme = veSetPreviewTheme
1058
1059function veSwitchToggle(btn, key) {
1060  btn.classList.toggle('on')
1061  const on = btn.classList.contains('on')
1062  if (key === 've-bar')    { veShowBar = on; renderVisual() }
1063  if (key === 've-stripe') { veShowStripe = on; renderVisual() }
1064  if (key === 'te-public') { veIsPublic = on }
1065}
1066window.veSwitchToggle = veSwitchToggle
1067
1068async function veHandleLogo(input) {
1069  const file = input.files[0]
1070  if (!file) return
1071  const zone = document.getElementById('ve-upload-zone')
1072  const clearBtn = document.getElementById('ve-logo-clear')
1073  zone.innerHTML = '<div class="ve-upload-icon">⏳</div>Uploading…'
1074
1075  // Read the file locally as a data URI so the SVG preview works immediately
1076  // without any CORS or external-fetch restrictions.
1077  const dataUri = await new Promise(resolve => {
1078    const reader = new FileReader()
1079    reader.onload = e => resolve(e.target.result)
1080    reader.readAsDataURL(file)
1081  })
1082  veLogoDataUri = dataUri
1083  renderVisual()
1084
1085  const formData = new FormData()
1086  formData.append('file', file)
1087
1088  try {
1089    const res = await fetch(`${API}/api/logos`, {
1090      method: 'POST',
1091      headers: { Authorization: `Bearer ${token}` },
1092      body: formData,
1093    })
1094    const data = await res.json()
1095    if (!res.ok) throw new Error(data.error || 'Upload failed')
1096    veLogoKey = data.key
1097    veLogoUrl = data.url
1098    zone.innerHTML = `<div class="ve-upload-icon">✓</div>${esc(file.name)}`
1099    clearBtn.style.display = 'block'
1100  } catch (err) {
1101    veLogoDataUri = null
1102    veLogoKey = null
1103    veLogoUrl = null
1104    renderVisual()
1105    zone.innerHTML = '<div class="ve-upload-icon">↑</div>Click to upload PNG, SVG, JPEG, or WebP'
1106    showToast(err.message, 'error')
1107  }
1108}
1109window.veHandleLogo = veHandleLogo
1110
1111function veClearLogo() {
1112  veLogoKey = null
1113  veLogoUrl = null
1114  veLogoDataUri = null
1115  document.getElementById('ve-upload-zone').innerHTML = '<div class="ve-upload-icon">↑</div>Click to upload PNG, SVG, JPEG, or WebP'
1116  document.getElementById('ve-logo-clear').style.display = 'none'
1117  document.getElementById('ve-logo-input').value = ''
1118  renderVisual()
1119}
1120window.veClearLogo = veClearLogo
1121
1122// ─── Code-mode image uploads ──────────────────────────────────────────────────
1123
1124async function veCodeHandleImage(input) {
1125  const file = input.files[0]
1126  if (!file) return
1127  const zone = document.getElementById('code-img-upload-zone')
1128  zone.innerHTML = '<div class="ve-upload-icon">⏳</div>Uploading…'
1129  const formData = new FormData()
1130  formData.append('file', file)
1131  const res = await fetch(`${API}/api/logos`, {
1132    method: 'POST',
1133    headers: { Authorization: `Bearer ${token}` },
1134    body: formData,
1135  })
1136  const data = await res.json().catch(() => ({}))
1137  zone.innerHTML = '<div class="ve-upload-icon">↑</div>Upload PNG, SVG, JPEG or WebP'
1138  input.value = ''
1139  if (!res.ok) { showToast(data.error || 'Upload failed', 'error'); return }
1140  veCodeImages.push({ key: data.key, url: data.url })
1141  renderCodeImageList()
1142}
1143window.veCodeHandleImage = veCodeHandleImage
1144
1145function renderCodeImageList() {
1146  const list = document.getElementById('code-img-list')
1147  if (!list) return
1148  list.innerHTML = veCodeImages.map((img, i) => {
1149    const fname = img.url.split('/').pop()
1150    return `<div style="background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:6px;padding:8px 10px;">
1151      <div style="font-size:0.62rem;font-family:var(--font-mono);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:6px;" title="${img.url}">${fname}</div>
1152      <div style="display:flex;gap:5px;">
1153        <button class="btn-ghost btn-xs" style="flex:1;" onclick="veCodeInsertImage(${i})">Insert</button>
1154        <button class="btn-ghost btn-xs" onclick="copyText('${img.url}',this)">Copy URL</button>
1155        <button class="btn-ghost btn-xs" style="color:var(--danger);" onclick="veCodeRemoveImage(${i})">✕</button>
1156      </div>
1157    </div>`
1158  }).join('')
1159}
1160window.renderCodeImageList = renderCodeImageList
1161
1162function veCodeInsertImage(i) {
1163  const img = veCodeImages[i]
1164  const textarea = document.getElementById('template-code-input')
1165  const pos = textarea.selectionStart
1166  const snippet = `\n  <image href="${img.url}" x="0" y="0" width="200" height="200" preserveAspectRatio="xMidYMid meet"/>`
1167  textarea.value = textarea.value.slice(0, pos) + snippet + textarea.value.slice(pos)
1168  updateTemplatePreview()
1169  textarea.focus()
1170}
1171window.veCodeInsertImage = veCodeInsertImage
1172
1173async function veCodeRemoveImage(i) {
1174  const img = veCodeImages[i]
1175  const parts = img.key.split('/')  // logos/userId/filename
1176  await api(`/api/logos/${parts[1]}/${parts[2]}`, { method: 'DELETE' })
1177  veCodeImages.splice(i, 1)
1178  renderCodeImageList()
1179}
1180window.veCodeRemoveImage = veCodeRemoveImage
1181
1182function veOnDataUrl(val) {
1183  veDataUrl = val
1184}
1185window.veOnDataUrl = veOnDataUrl
1186
1187// Populate the sign-key selects and show/hide the Pro gate.
1188function vePopulateSignKeySelect() {
1189  const isPro = ['pro', 'scale'].includes(user?.plan)
1190  const signedKeys = keys.filter(k => k.is_active && k.signing_secret)
1191
1192  for (const prefix of ['ve', 'code']) {
1193    const gate   = document.getElementById(`${prefix}-sign-pro-gate`)
1194    const body   = document.getElementById(`${prefix}-sign-body`)
1195    const sel    = document.getElementById(`${prefix}-sign-key`)
1196    const noKeys = document.getElementById(`${prefix}-sign-no-keys`)
1197    if (!gate || !body || !sel || !noKeys) continue
1198
1199    if (!isPro) {
1200      gate.style.display = 'block'
1201      body.style.display = 'none'
1202      continue
1203    }
1204    gate.style.display = 'none'
1205    body.style.display = 'block'
1206
1207    sel.innerHTML = '<option value="">— select a key —</option>' +
1208      signedKeys.map(k => `<option value="${esc(k.signing_secret)}">${esc(k.name)} (${esc(k.key_prefix)}…)</option>`).join('')
1209
1210    noKeys.style.display = signedKeys.length === 0 ? 'block' : 'none'
1211    sel.style.display    = signedKeys.length === 0 ? 'none'  : 'block'
1212  }
1213}
1214
1215async function veUpdateSignedUrl() {
1216  const prefix = teMode === 'visual' ? 've' : 'code'
1217  const sel    = document.getElementById(`${prefix}-sign-key`)
1218  const out    = document.getElementById(`${prefix}-signed-url-out`)
1219  const val    = document.getElementById(`${prefix}-signed-url-val`)
1220  if (!sel || !out || !val) return
1221
1222  const secret = sel.value
1223  if (!secret) { out.style.display = 'none'; return }
1224
1225  // Build canonical param string: sorted, excluding key and s (per signing spec)
1226  const params = { theme: `custom_${editingTemplateId || 'TEMPLATE_ID'}`, title: 'Hello World' }
1227  if (veDataUrl) params.data_url = veDataUrl
1228  const canonical = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&')
1229
1230  try {
1231    const enc = new TextEncoder()
1232    const cryptoKey = await crypto.subtle.importKey(
1233      'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
1234    )
1235    const sig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(canonical))
1236    const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('')
1237    val.textContent = `https://api.metashot.io/og?key=msk_...&${canonical}&s=${hex}`
1238    out.style.display = 'block'
1239  } catch {
1240    out.style.display = 'none'
1241  }
1242}
1243window.veUpdateSignedUrl = veUpdateSignedUrl
1244
1245// Sync the font <select> to match the veActiveFontFamily state variable.
1246// Custom fonts not in the select are added as a transient <option> so the
1247// saved config can round-trip without losing the chosen family name.
1248function renderVeFontTiles() {
1249  const sel = document.getElementById('ve-font')
1250  if (!sel) return
1251  // Remove any previously injected custom option
1252  const existing = sel.querySelector('option[data-custom]')
1253  if (existing) existing.remove()
1254  const BUILTIN = ['Inter', 'Fira Code']
1255  if (!BUILTIN.includes(veActiveFontFamily)) {
1256    const opt = document.createElement('option')
1257    opt.value = veActiveFontFamily
1258    opt.textContent = veActiveFontFamily + ' (custom)'
1259    opt.dataset.custom = '1'
1260    sel.appendChild(opt)
1261  }
1262  sel.value = veActiveFontFamily
1263}
1264window.renderVeFontTiles = renderVeFontTiles
1265
1266function veSelectFont(family) {
1267  veActiveFontFamily = family
1268  renderVeFontTiles()
1269  renderVisual()
1270}
1271window.veSelectFont = veSelectFont
1272
1273// Generates a template-ready SVG with {{title}}, {{subtitle}}, {{author}} placeholders.
1274// The renderer does simple string replacement, so we use a fixed single-line layout
1275// rather than the dynamic word-wrap used in the live preview.
1276function buildVisualSvgTemplate(cfg) {
1277  const W = 1200, H = 630
1278  let bgFill = cfg.bg, fg = cfg.fg, accent = cfg.accent
1279
1280  // Apply the same preset-theme overrides used in the live preview so the
1281  // saved PNG matches exactly what the user sees.
1282  if (vePreviewTheme === 'dark')       { bgFill = '#0f172a'; fg = '#f1f5f9'; accent = '#38bdf8' }
1283  else if (vePreviewTheme === 'light') { bgFill = '#ffffff'; fg = '#0f172a'; accent = '#6366f1' }
1284  else if (vePreviewTheme === 'code')  { bgFill = '#020817'; fg = '#e2e8f0'; accent = '#22d3ee' }
1285  else if (cfg.bgMode === 'gradient')  bgFill = 'url(#veg)'
1286  else if (cfg.bgMode === 'dark')      bgFill = '#0f0c29'
1287
1288  const sz = cfg.titleSize
1289  const pos = cfg.position
1290  let ty
1291  if (pos === 'center') ty = Math.round(H / 2 - sz / 2)
1292  else if (pos === 'top') ty = 130
1293  else ty = 290  // bottom, assumes single line
1294
1295  const defs = (cfg.bgMode === 'gradient')
1296    ? `<defs><linearGradient id="veg" x1="0%" y1="0%" x2="100%" y2="100%">
1297        <stop offset="0%" stop-color="${cfg.gradFrom}"/>
1298        <stop offset="100%" stop-color="${cfg.gradTo}"/>
1299      </linearGradient></defs>` : ''
1300
1301  let dec = ''
1302  if (cfg.shape === 'circles') {
1303    dec = `<circle cx="${W-180}" cy="${H/2}" r="220" fill="${accent}" opacity="0.07"/>
1304           <circle cx="${W-80}" cy="80" r="120" fill="${accent}" opacity="0.05"/>`
1305  } else if (cfg.shape === 'grid') {
1306    dec = Array.from({length:8},(_,i)=>
1307      `<line x1="${i*180}" y1="0" x2="${i*180}" y2="${H}" stroke="${accent}" stroke-width="0.5" opacity="0.08"/>`)
1308      .concat(Array.from({length:5},(_,i)=>
1309      `<line x1="0" y1="${i*160}" x2="${W}" y2="${i*160}" stroke="${accent}" stroke-width="0.5" opacity="0.08"/>`))
1310      .join('')
1311  } else if (cfg.shape === 'dots') {
1312    const d = []
1313    for (let x=80;x<W;x+=64) for (let y=40;y<H;y+=64)
1314      d.push(`<circle cx="${x}" cy="${y}" r="1.5" fill="${accent}" opacity="0.12"/>`)
1315    dec = d.join('')
1316  }
1317
1318  const bar    = cfg.showBar    ? `<rect x="0" y="60" width="6" height="${H-120}" fill="${accent}" rx="3"/>` : ''
1319  const stripe = cfg.showStripe ? `<rect x="0" y="${H-8}" width="${W}" height="8" fill="${accent}" opacity="0.8"/>` : ''
1320
1321  const font = esc(cfg.font)
1322  const titleSvg = `<text x="80" y="${ty}" font-family="${font}" font-size="${sz}" font-weight="800" fill="${fg}" letter-spacing="-2">{{title}}</text>`
1323  const subSvg   = `<text x="80" y="${ty + sz + 38}" font-family="${font}" font-size="30" fill="${fg}" opacity="0.6" font-weight="400">{{subtitle}}</text>`
1324  const authSvg  = `<text x="80" y="${H-52}" font-family="${font}" font-size="22" fill="${fg}" opacity="0.45" font-weight="500">&#xB7; {{author}}</text>`
1325  const logoSvg  = cfg.logo ? `<image href="${cfg.logo}" x="${W-160}" y="36" width="84" height="84" preserveAspectRatio="xMidYMid meet"/>` : ''
1326
1327  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
1328  ${defs}
1329  <rect width="${W}" height="${H}" fill="${bgFill}"/>
1330  ${dec}
1331  ${bar}
1332  ${logoSvg}
1333  ${titleSvg}
1334  ${subSvg}
1335  ${authSvg}
1336  ${stripe}
1337</svg>`
1338}
1339
1340function veGetConfig() {
1341  return {
1342    title:     document.getElementById('ve-title').value || 'Hello, World!',
1343    subtitle:  document.getElementById('ve-subtitle').value,
1344    author:    document.getElementById('ve-author').value,
1345    font:      veActiveFontFamily,
1346    titleSize: parseInt(document.getElementById('ve-size').value),
1347    fg:        document.getElementById('ve-fg').value,
1348    accent:    document.getElementById('ve-accent').value,
1349    bg:        document.getElementById('ve-bg').value,
1350    gradFrom:  document.getElementById('ve-g1').value,
1351    gradTo:    document.getElementById('ve-g2').value,
1352    bgMode: veBgMode, showBar: veShowBar, showStripe: veShowStripe,
1353    shape: veShape, logo: veLogoUrl,
1354    position: document.getElementById('ve-pos').value,
1355  }
1356}
1357
1358function buildVisualSvg(cfg, theme) {
1359  const W = 1200, H = 630
1360  let bgFill = cfg.bg, fg = cfg.fg, accent = cfg.accent
1361
1362  if (theme === 'dark')       { bgFill = '#0f172a'; fg = '#f1f5f9'; accent = '#38bdf8' }
1363  else if (theme === 'light') { bgFill = '#ffffff'; fg = '#0f172a'; accent = '#6366f1' }
1364  else if (theme === 'code')  { bgFill = '#020817'; fg = '#e2e8f0'; accent = '#22d3ee' }
1365  else if (cfg.bgMode === 'gradient') bgFill = 'url(#veg)'
1366  else if (cfg.bgMode === 'dark')     bgFill = '#0f0c29'
1367
1368  const sz = cfg.titleSize
1369  const maxCh = Math.floor(26 * (88 / sz))
1370  const words = cfg.title.split(' ')
1371  const lines = []
1372  let cur = ''
1373  for (const w of words) {
1374    if ((cur + ' ' + w).trim().length <= maxCh) cur = (cur + ' ' + w).trim()
1375    else { if (cur) lines.push(cur); cur = w }
1376  }
1377  if (cur) lines.push(cur)
1378  const titleLines = lines.slice(0, 3)
1379
1380  const pos = cfg.position
1381  let ty
1382  if (pos === 'center') ty = H/2 - (titleLines.length * (sz + 8))/2
1383  else if (pos === 'top') ty = 130
1384  else ty = titleLines.length === 1 ? 290 : titleLines.length === 2 ? 260 : 230
1385
1386  const defs = (cfg.bgMode === 'gradient' && theme === 'current')
1387    ? `<defs><linearGradient id="veg" x1="0%" y1="0%" x2="100%" y2="100%">
1388        <stop offset="0%" stop-color="${cfg.gradFrom}"/>
1389        <stop offset="100%" stop-color="${cfg.gradTo}"/>
1390      </linearGradient></defs>` : ''
1391
1392  let dec = ''
1393  if (cfg.shape === 'circles') {
1394    dec = `<circle cx="${W-180}" cy="${H/2}" r="220" fill="${accent}" opacity="0.07"/>
1395           <circle cx="${W-80}" cy="80" r="120" fill="${accent}" opacity="0.05"/>`
1396  } else if (cfg.shape === 'grid') {
1397    dec = Array.from({length:8},(_,i)=>
1398      `<line x1="${i*180}" y1="0" x2="${i*180}" y2="${H}" stroke="${accent}" stroke-width="0.5" opacity="0.08"/>`)
1399      .concat(Array.from({length:5},(_,i)=>
1400      `<line x1="0" y1="${i*160}" x2="${W}" y2="${i*160}" stroke="${accent}" stroke-width="0.5" opacity="0.08"/>`))
1401      .join('')
1402  } else if (cfg.shape === 'dots') {
1403    const d = []
1404    for (let x=80;x<W;x+=64) for (let y=40;y<H;y+=64)
1405      d.push(`<circle cx="${x}" cy="${y}" r="1.5" fill="${accent}" opacity="0.12"/>`)
1406    dec = d.join('')
1407  }
1408
1409  const bar    = cfg.showBar    ? `<rect x="0" y="60" width="6" height="${H-120}" fill="${accent}" rx="3"/>` : ''
1410  const stripe = cfg.showStripe ? `<rect x="0" y="${H-8}" width="${W}" height="8" fill="${accent}" opacity="0.8"/>` : ''
1411
1412  const titleSvg = titleLines.map((line, i) =>
1413    `<text x="80" y="${ty + i*(sz+10)}" font-family="${esc(cfg.font)}" font-size="${sz}"
1414      font-weight="800" fill="${fg}" letter-spacing="-2">${esc(line)}</text>`
1415  ).join('\n')
1416
1417  const subY = ty + titleLines.length * (sz + 10) + 28
1418  const subSvg = cfg.subtitle
1419    ? `<text x="80" y="${subY}" font-family="${esc(cfg.font)}" font-size="30"
1420        fill="${fg}" opacity="0.6" font-weight="400">${esc(cfg.subtitle.slice(0,90))}</text>` : ''
1421
1422  const authSvg = cfg.author
1423    ? `<text x="80" y="${H-52}" font-family="${esc(cfg.font)}" font-size="22"
1424        fill="${fg}" opacity="0.45" font-weight="500">&#xB7; ${esc(cfg.author.slice(0,50))}</text>` : ''
1425
1426  // Use the local data URI for the live preview — avoids CORS and external-fetch
1427  // restrictions that prevent <image href="url"> from loading in innerHTML SVG.
1428  const logoSrc = veLogoDataUri || cfg.logo
1429  const logoSvg = logoSrc
1430    ? `<image href="${logoSrc}" x="${W-160}" y="36" width="84" height="84" preserveAspectRatio="xMidYMid meet"/>` : ''
1431
1432  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
1433  ${defs}
1434  <rect width="${W}" height="${H}" fill="${bgFill}"/>
1435  ${dec}
1436  ${bar}
1437  ${logoSvg}
1438  ${titleSvg}
1439  ${subSvg}
1440  ${authSvg}
1441  ${stripe}
1442</svg>`
1443}
1444
1445function renderVisual() {
1446  const frame = document.getElementById('te-canvas-frame')
1447  if (!frame) return
1448  const cfg = veGetConfig()
1449  let svg = buildVisualSvg(cfg, vePreviewTheme)
1450  // Inject @font-face for custom fonts so browser preview matches the rendered PNG
1451  const BUILTIN_FONTS = ['Inter', 'Fira Code']
1452  if (!BUILTIN_FONTS.includes(cfg.font) && veCustomFonts.length) {
1453    const fontFaceCSS = veCustomFonts.map(f => {
1454      const wPart = f.weight ? `font-weight:${f.weight};` : ''
1455      return `@font-face{font-family:'${f.family.replace(/'/g,"\\'")}';${wPart}src:url('${f.url}');}`
1456    }).join('')
1457    svg = svg.replace(/(<svg[^>]*>)/, `$1<defs><style>${fontFaceCSS}</style></defs>`)
1458  }
1459  frame.innerHTML = svg
1460  const kb = (new Blob([svg]).size / 1024).toFixed(1)
1461  const sizeChip = document.getElementById('te-chip-size')
1462  if (sizeChip) sizeChip.textContent = `~${kb} KB`
1463}
1464
1465function veResetVisualDefaults() {
1466  veBgMode = 'solid'
1467  veShape = 'circles'
1468  veShowBar = true
1469  veShowStripe = true
1470  veLogoKey      = null
1471  veLogoUrl      = null
1472  veLogoDataUri  = null
1473  veDataUrl      = ''
1474  vePreviewTheme = 'current'
1475  veActivePreset = 'custom'
1476
1477  // Deactivate all pills; highlight Custom since no preset is applied
1478  document.querySelectorAll('.te-pill').forEach(p => p.classList.remove('active'))
1479  document.getElementById('ve-preset-custom')?.classList.add('active')
1480
1481  const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
1482  set('ve-title', 'Hello, World!')
1483  set('ve-subtitle', '')
1484  set('ve-author', '')
1485  veActiveFontFamily = 'Inter'
1486  veCustomFonts = []
1487  renderVeFontTiles()
1488  set('ve-size', '88')
1489  set('ve-fg', '#ffffff');   set('ve-fg-hex', '#ffffff')
1490  set('ve-accent', '#a78bfa'); set('ve-accent-hex', '#a78bfa')
1491  set('ve-bg', '#302b63');   set('ve-bg-hex', '#302b63')
1492  set('ve-g1', '#0f0c29');   set('ve-g1-hex', '#0f0c29')
1493  set('ve-g2', '#24243e');   set('ve-g2-hex', '#24243e')
1494  set('ve-pos', 'bottom')
1495  const sizeDisplay = document.getElementById('ve-size-display')
1496  if (sizeDisplay) sizeDisplay.textContent = '88'
1497
1498  // bg mode tabs
1499  document.querySelectorAll('#ve-bg-tabs .ve-tab').forEach((t, i) => t.classList.toggle('active', i === 0))
1500  const bgSolid = document.getElementById('ve-bg-solid')
1501  const bgGrad  = document.getElementById('ve-bg-gradient')
1502  if (bgSolid) bgSolid.style.display = ''
1503  if (bgGrad)  bgGrad.style.display = 'none'
1504
1505  // shapes
1506  document.querySelectorAll('.ve-shape-btn').forEach(b => b.classList.toggle('active', b.title === 'Circles'))
1507
1508  // switches
1509  document.getElementById('sw-ve-bar')?.classList.add('on')
1510  document.getElementById('sw-ve-stripe')?.classList.add('on')
1511
1512  // logo
1513  const zone = document.getElementById('ve-upload-zone')
1514  if (zone) zone.innerHTML = '<div class="ve-upload-icon">↑</div>Click to upload PNG, SVG, JPEG, or WebP'
1515  const clearBtn = document.getElementById('ve-logo-clear')
1516  if (clearBtn) clearBtn.style.display = 'none'
1517  const logoInput = document.getElementById('ve-logo-input')
1518  if (logoInput) logoInput.value = ''
1519  // data url + signing
1520  const duEl = document.getElementById('ve-data-url')
1521  if (duEl) duEl.value = ''
1522  for (const prefix of ['ve', 'code']) {
1523    const sel = document.getElementById(`${prefix}-sign-key`)
1524    if (sel) sel.value = ''
1525    const out = document.getElementById(`${prefix}-signed-url-out`)
1526    if (out) out.style.display = 'none'
1527  }
1528}
1529
1530function veRestoreVisualConfig(cfg) {
1531  veBgMode    = cfg.bgMode    || 'solid'
1532  veShape     = cfg.shape     || 'circles'
1533  veShowBar   = cfg.showBar   !== false
1534  veShowStripe = cfg.showStripe !== false
1535  veLogoKey      = cfg.logo_key    || null
1536  veLogoUrl      = cfg.logo_url    || null
1537  veLogoDataUri  = null
1538  veDataUrl      = ''
1539  vePreviewTheme = cfg.previewTheme || 'current'
1540
1541  // Pre-fetch the logo as a data URI so the SVG preview renders it without
1542  // relying on external URL loading in the innerHTML context.
1543  if (veLogoUrl) {
1544    fetch(veLogoUrl)
1545      .then(r => r.blob())
1546      .then(blob => new Promise(resolve => {
1547        const reader = new FileReader()
1548        reader.onload = e => resolve(e.target.result)
1549        reader.readAsDataURL(blob)
1550      }))
1551      .then(uri => { veLogoDataUri = uri; renderVisual() })
1552      .catch(() => {})
1553  }
1554
1555  vePreviewTheme = 'current'
1556  veActivePreset = cfg.activePreset || 'custom'
1557  // Activate the saved preset pill (or Custom if none saved)
1558  document.querySelectorAll('.te-pill').forEach(p => p.classList.remove('active'))
1559  const presetBtn = document.getElementById(`ve-preset-${veActivePreset}`)
1560  ;(presetBtn || document.getElementById('ve-preset-custom'))?.classList.add('active')
1561
1562  const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
1563  set('ve-title',    cfg.defaultTitle    || 'Hello, World!')
1564  set('ve-subtitle', cfg.defaultSubtitle || '')
1565  set('ve-author',   cfg.defaultAuthor   || '')
1566  const BUILTIN_FONTS = ['Inter', 'Fira Code']
1567  veActiveFontFamily = cfg.font || 'Inter'
1568  // Restore custom font entries if the font is not a builtin
1569  if (!BUILTIN_FONTS.includes(veActiveFontFamily) && Array.isArray(cfg.fonts) && cfg.fonts.length) {
1570    veCustomFonts = cfg.fonts.filter(f => f.family === veActiveFontFamily)
1571  } else {
1572    veCustomFonts = []
1573  }
1574  renderVeFontTiles()
1575  const sz = String(cfg.titleSize || 88)
1576  set('ve-size', sz)
1577  const sizeDisplay = document.getElementById('ve-size-display')
1578  if (sizeDisplay) sizeDisplay.textContent = sz
1579  set('ve-fg', cfg.fg || '#ffffff');       set('ve-fg-hex', cfg.fg || '#ffffff')
1580  set('ve-accent', cfg.accent || '#a78bfa'); set('ve-accent-hex', cfg.accent || '#a78bfa')
1581  set('ve-bg', cfg.bg || '#302b63');       set('ve-bg-hex', cfg.bg || '#302b63')
1582  set('ve-g1', cfg.gradFrom || '#0f0c29'); set('ve-g1-hex', cfg.gradFrom || '#0f0c29')
1583  set('ve-g2', cfg.gradTo   || '#24243e'); set('ve-g2-hex', cfg.gradTo   || '#24243e')
1584  set('ve-pos', cfg.position || 'bottom')
1585
1586  // bg mode tabs
1587  document.querySelectorAll('#ve-bg-tabs .ve-tab').forEach(t => {
1588    const m = t.textContent.trim().toLowerCase()
1589    t.classList.toggle('active', m === veBgMode)
1590  })
1591  const bgSolid = document.getElementById('ve-bg-solid')
1592  const bgGrad  = document.getElementById('ve-bg-gradient')
1593  if (bgSolid) bgSolid.style.display = veBgMode === 'solid' ? '' : 'none'
1594  if (bgGrad)  bgGrad.style.display  = veBgMode === 'gradient' ? '' : 'none'
1595
1596  // shape
1597  document.querySelectorAll('.ve-shape-btn').forEach(b =>
1598    b.classList.toggle('active', b.title.toLowerCase() === veShape))
1599
1600  // switches
1601  document.getElementById('sw-ve-bar')?.classList.toggle('on', veShowBar)
1602  document.getElementById('sw-ve-stripe')?.classList.toggle('on', veShowStripe)
1603
1604  // logo
1605  const zone = document.getElementById('ve-upload-zone')
1606  const clearBtn = document.getElementById('ve-logo-clear')
1607  if (veLogoUrl && zone) {
1608    zone.innerHTML = '<div class="ve-upload-icon">✓</div>Logo uploaded'
1609    if (clearBtn) clearBtn.style.display = 'block'
1610  } else {
1611    if (zone) zone.innerHTML = '<div class="ve-upload-icon">↑</div>Click to upload PNG, SVG, JPEG, or WebP'
1612    if (clearBtn) clearBtn.style.display = 'none'
1613  }
1614  const logoInput = document.getElementById('ve-logo-input')
1615  if (logoInput) logoInput.value = ''
1616  // signing
1617  for (const prefix of ['ve', 'code']) {
1618    const sel = document.getElementById(`${prefix}-sign-key`)
1619    if (sel) sel.value = ''
1620    const out = document.getElementById(`${prefix}-signed-url-out`)
1621    if (out) out.style.display = 'none'
1622  }
1623}
1624
1625function updateTemplatePreview() {
1626  const code = document.getElementById('template-code-input').value
1627  const frame = document.getElementById('te-canvas-frame')
1628  if (!frame) return
1629
1630  if (!code.trim()) {
1631    frame.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:0.85rem;">Preview will appear here</div>'
1632    return
1633  }
1634
1635  const previewTitle    = document.getElementById('code-default-title')?.value.trim()    || 'Hello, World!'
1636  const previewSubtitle = document.getElementById('code-default-subtitle')?.value.trim() || 'This is a live preview of your template.'
1637  const previewAuthor   = document.getElementById('code-default-author')?.value.trim()   || 'MetaShot.io'
1638  let html = code
1639    .replace(/\{\{\s*title\s*\}\}/g, esc(previewTitle))
1640    .replace(/\{\{\s*subtitle\s*\}\}/g, esc(previewSubtitle))
1641    .replace(/\{\{\s*author\s*\}\}/g, esc(previewAuthor))
1642
1643  const validFonts = brandFonts.filter(f => f.family && f.family.trim() && f.url && f.url.trim())
1644  if (validFonts.length) {
1645    const fontFaceCSS = validFonts.map(f => {
1646      const weightPart = f.weight ? `font-weight:${f.weight};` : ''
1647      return `@font-face{font-family:'${f.family.replace(/'/g,"\\'")}';${weightPart}src:url('${f.url}');}`
1648    }).join('')
1649    html = html.replace(/(<svg[^>]*>)/, `$1<defs><style>${fontFaceCSS}</style></defs>`)
1650  }
1651
1652  if (!html.includes('<svg')) {
1653    frame.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--danger);">Valid SVG required</div>'
1654    return
1655  }
1656
1657  frame.innerHTML = html
1658}
1659
1660function insertSvgSnippet(type) {
1661  const textarea = document.getElementById('template-code-input')
1662  const curPos = textarea.selectionStart
1663  let snippet = ''
1664  if (type === 'text') snippet = `\n  <text x="100" y="200" font-family="Inter" font-size="60" fill="#ffffff">{{title}}</text>`
1665  if (type === 'rect') snippet = `\n  <rect x="0" y="0" width="1200" height="630" fill="#000000"/>`
1666  if (type === 'image') snippet = `\n  <image href="https://example.com/image.png" x="100" y="100" width="200" height="200"/>`
1667  const text = textarea.value
1668  textarea.value = text.slice(0, curPos) + snippet + text.slice(curPos)
1669  updateTemplatePreview()
1670  textarea.focus()
1671}
1672
1673// ─── Brand Fonts ─────────────────────────────────────────────────────────────
1674
1675// Curated list of popular Google Fonts available via @fontsource on jsDelivr
1676const FONT_CATALOG = [
1677  'Abel','Abril Fatface','Alegreya','Alegreya Sans','Arimo','Arvo',
1678  'Asap','Barlow','Barlow Condensed','Bitter','Cabin','Cairo',
1679  'Caveat','Cinzel','Comfortaa','Cormorant Garamond','Crimson Text',
1680  'Dancing Script','DM Sans','DM Serif Display','Domine','Dosis',
1681  'EB Garamond','Exo 2','Figtree','Fjalla One','Frank Ruhl Libre',
1682  'Geologica','Heebo','IBM Plex Mono','IBM Plex Sans','IBM Plex Serif',
1683  'Inconsolata','Josefin Sans','Josefin Slab','Jost','Kanit',
1684  'Karla','Kumbh Sans','Lato','Libre Baskerville','Libre Franklin',
1685  'Lora','Manrope','Maven Pro','Merriweather','Merriweather Sans',
1686  'Montserrat','Mukta','Mulish','Noto Sans','Noto Serif',
1687  'Nunito','Nunito Sans','Open Sans','Oswald','Outfit',
1688  'Overpass','Oxygen','Pacifico','Pathway Gothic One','Playfair Display',
1689  'Poppins','PT Sans','PT Serif','Quicksand','Raleway',
1690  'Readex Pro','Roboto','Roboto Condensed','Roboto Mono','Roboto Slab',
1691  'Rubik','Saira','Schibsted Grotesk','Signika','Sora',
1692  'Source Code Pro','Source Sans 3','Space Grotesk','Space Mono',
1693  'Spectral','Titillium Web','Ubuntu','Ubuntu Condensed','Ubuntu Mono',
1694  'Varela Round','Vollkorn','Work Sans','Yanone Kaffeesatz','Zilla Slab',
1695]
1696
1697function fontNameToId(name) {
1698  return name.toLowerCase().replace(/\s+/g, '-')
1699}
1700
1701function fontToUrl(family, weight = 400) {
1702  const id = fontNameToId(family)
1703  return `https://cdn.jsdelivr.net/npm/@fontsource/${id}/files/${id}-latin-${weight}-normal.woff2`
1704}
1705
1706let brandFonts = []
1707let fontSearchResults = []
1708let fontSearchHighlight = -1
1709
1710function renderBrandFontsList() {
1711  const list = document.getElementById('brand-fonts-list')
1712  if (!list) return
1713  // Group entries by family — show one tag per font family
1714  const families = [...new Set(brandFonts.map(f =>
1714 f.family).filter(Boolean))]
1715  if (families.length === 0) {
1716    list.innerHTML = ''
1717    return
1718  }
1719  list.innerHTML = families.map(family => {
1720    const weights = brandFonts.filter(f => f.family === family && f.weight).map(f => f.weight).sort((a, b) => a - b)
1721    const weightLabel = weights.length ? `weights: ${weights.join(', ')}` : 'weight: 400'
1722    return `
1723      <div style="display:flex; align-items:center; justify-content:space-between; padding:7px 10px; background:rgba(255,255,255,0.04); border:1px solid var(--border); border-radius:6px;">
1724        <div>
1725          <span style="font-size:0.85rem; color:white; font-weight:500;">${esc(family)}</span>
1726          <span style="font-size:0.7rem; color:var(--muted); margin-left:8px;">font-family="${esc(family)}" · ${weightLabel}</span>
1727        </div>
1728        <button class="btn-ghost btn-xs" data-family="${family.replace(/"/g,'&quot;')}" style="padding:4px 8px; font-size:0.75rem; color:var(--muted);">Remove</button>
1729      </div>
1730    `
1731  }).join('')
1732  list.querySelectorAll('button[data-family]').forEach(btn => {
1733    btn.addEventListener('click', () => {
1734      const fam = btn.dataset.family
1735      brandFonts = brandFonts.filter(f => f.family !== fam)
1736      renderBrandFontsList()
1737      updateTemplatePreview()
1738    })
1739  })
1740}
1741
1742function onFontSearchInput(query) {
1743  const resultsEl = document.getElementById('font-search-results')
1744  if (!resultsEl) return
1745  const q = query.trim().toLowerCase()
1746  if (!q) { resultsEl.style.display = 'none'; return }
1747
1748  fontSearchResults = FONT_CATALOG.filter(f =>
1749    f.toLowerCase().includes(q) && !brandFonts.find(b => b.family === f)
1750  ).slice(0, 10)
1751  fontSearchHighlight = -1
1752
1753  if (!fontSearchResults.length) { resultsEl.style.display = 'none'; return }
1754
1755  resultsEl.innerHTML = fontSearchResults.map((f, i) =>
1756    `<div data-idx="${i}" style="padding:8px 12px; cursor:pointer; font-size:0.85rem; color:var(--muted);">${esc(f)}</div>`
1757  ).join('')
1758  resultsEl.querySelectorAll('[data-idx]').forEach(item => {
1759    item.addEventListener('mousedown', e => {
1760      e.preventDefault()
1761      selectFont(fontSearchResults[parseInt(item.dataset.idx)])
1762    })
1763  })
1764  resultsEl.style.display = 'block'
1765}
1766window.onFontSearchInput = onFontSearchInput
1767
1768function onFontSearchKey(e) {
1769  const resultsEl = document.getElementById('font-search-results')
1770  if (!resultsEl || resultsEl.style.display === 'none') return
1771  const items = resultsEl.querySelectorAll('[data-idx]')
1772  if (e.key === 'ArrowDown') {
1773    e.preventDefault()
1774    fontSearchHighlight = Math.min(fontSearchHighlight + 1, items.length - 1)
1775  } else if (e.key === 'ArrowUp') {
1776    e.preventDefault()
1777    fontSearchHighlight = Math.max(fontSearchHighlight - 1, 0)
1778  } else if (e.key === 'Enter' && fontSearchHighlight >= 0) {
1779    e.preventDefault()
1780    selectFont(fontSearchResults[fontSearchHighlight])
1781    return
1782  } else if (e.key === 'Escape') {
1783    resultsEl.style.display = 'none'
1784    return
1785  }
1786  items.forEach((item, i) => {
1787    item.style.background = i === fontSearchHighlight ? 'rgba(99,102,241,0.15)' : ''
1788    item.style.color = i === fontSearchHighlight ? 'white' : ''
1789  })
1790}
1791window.onFontSearchKey = onFontSearchKey
1792
1793function selectFont(family) {
1794  if (!brandFonts.find(f => f.family === family)) {
1795    // Load 300, 400, and 700 weight variants so SVG templates using any font-weight render correctly
1796    for (const weight of [300, 400, 700]) {
1797      brandFonts.push({ family, url: fontToUrl(family, weight), weight })
1798    }
1799    renderBrandFontsList()
1800    updateTemplatePreview()
1801  }
1802  const input = document.getElementById('font-search-input')
1803  if (input) input.value = ''
1804  const resultsEl = document.getElementById('font-search-results')
1805  if (resultsEl) resultsEl.style.display = 'none'
1806  fontSearchResults = []
1807}
1808
1809async function saveTemplate() {
1810  const name = document.getElementById('template-name-input').value.trim()
1811  document.getElementById('template-editor-error').classList.add('hidden')
1812
1813  if (!name) {
1814    showToast('Template name is required.', 'error')
1815    document.getElementById('template-name-input').focus()
1816    return
1817  }
1818
1819  let config
1820  if (teMode === 'visual') {
1821    const cfg = veGetConfig()
1822    const source = buildVisualSvgTemplate(cfg)
1823    config = {
1824      version: 1,
1825      mode: 'visual',
1826      bgMode: cfg.bgMode, bg: cfg.bg,
1827      gradFrom: cfg.gradFrom, gradTo: cfg.gradTo,
1828      fg: cfg.fg, accent: cfg.accent,
1829      font: cfg.font, titleSize: cfg.titleSize,
1830      position: cfg.position, showBar: cfg.showBar,
1831      showStripe: cfg.showStripe, shape: cfg.shape,
1832      ...(veActivePreset !== 'custom' ? { activePreset: veActivePreset } : {}),
1833      ...(veLogoKey ? { logo_key: veLogoKey, logo_url: veLogoUrl } : {}),
1834      // Default content shown when URL params are absent
1835      ...(cfg.title    ? { defaultTitle:    cfg.title    } : {}),
1836      ...(cfg.subtitle ? { defaultSubtitle: cfg.subtitle } : {}),
1837      ...(cfg.author   ? { defaultAuthor:   cfg.author   } : {}),
1838      source,
1839    }
1840  } else {
1841    const source = document.getElementById('template-code-input').value.trim()
1842    if (!source) { showToast('SVG source is required.', 'error'); return }
1843    const fonts = brandFonts.filter(f => f.family.trim() && f.url.trim())
1844    const defaultTitle    = document.getElementById('code-default-title')?.value.trim()
1845    const defaultSubtitle = document.getElementById('code-default-subtitle')?.value.trim()
1846    const defaultAuthor   = document.getElementById('code-default-author')?.value.trim()
1847    config = {
1848      mode: 'code',
1849      source,
1850      ...(fonts.length       ? { fonts }                        : {}),
1851      ...(defaultTitle       ? { defaultTitle }                 : {}),
1852      ...(defaultSubtitle    ? { defaultSubtitle }              : {}),
1853      ...(defaultAuthor      ? { defaultAuthor }                : {}),
1854      ...(veCodeImages.length ? { images: veCodeImages }         : {}),
1855      ...(veDataUrl          ? { data_url: veDataUrl }          : {}),
1856    }
1857  }
1858
1859  const btn = document.getElementById('template-editor-submit')
1860  const origText = btn.textContent
1861  btn.textContent = 'Saving…'; btn.disabled = true
1862
1863  const endpoint = editingTemplateId ? `/api/templates/${editingTemplateId}` : '/api/templates'
1864  const method = editingTemplateId ? 'PUT' : 'POST'
1865
1866  const { ok, data } = await api(endpoint, {
1867    method,
1868    body: JSON.stringify({ name, config, is_public: veIsPublic }),
1869  })
1870
1871  btn.textContent = origText; btn.disabled = false
1872
1873  if (!ok) { showToast(data.error || 'Failed to save template', 'error'); return }
1874
1875  if (data.id) {
1876    document.getElementById('te-url-bar').textContent =
1877      `api.metashot.io/og?key=msk_...&theme=custom_${data.id}&title=Hello`
1878  }
1879
1880  closeTemplateEditor()
1881  await loadTemplates()
1882}
1883
1884async function deleteTemplate(id) {
1885  const confirmed = await customConfirm('Delete Template', 'Are you sure you want to delete this template? Any URLs currently using it will instantly break and return a 404.', 'Delete Template', true);
1886  if (!confirmed) return;
1887  const { ok, data } = await api(`/api/templates/${id}`, { method: 'DELETE' })
1888  if (!ok) { showToast(data?.error || 'Failed to delete template', 'error'); return }
1889  await loadTemplates()
1890}
1891
1892// ─── Preview ──────────────────────────────────────────────────────────────────
1893
1894function renderSvg({ title = '', subtitle = '', author = '', theme = 'gradient' } = {}) {
1895  const WIDTH = 1200, HEIGHT = 630
1896  const themes = {
1897    gradient: { bg: 'url(#grad)', fg: '#ffffff', accent: '#a78bfa', muted: 'rgba(255,255,255,0.7)',
1898      defs: `<defs><linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0f0c29"/><stop offset="50%" style="stop-color:#302b63"/><stop offset="100%" style="stop-color:#24243e"/></linearGradient></defs>` },
1899    dark: { bg: '#0f172a', fg: '#f1f5f9', accent: '#38bdf8', muted: '#94a3b8', defs: '' },
1900    light: { bg: '#ffffff', fg: '#0f172a', accent: '#6366f1', muted: '#64748b', defs: '' },
1901    code: { bg: 'url(#codeGrad)', fg: '#e2e8f0', accent: '#22d3ee', muted: '#64748b',
1902      defs: `<defs><linearGradient id="codeGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#020817;stop-opacity:1"/><stop offset="100%" style="stop-color:#0f1629;stop-opacity:1"/></linearGradient><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="60" result="coloredBlur"/><feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>` },
1903    minimal: { bg: '#fafafa', fg: '#111111', accent: '#111111', muted: '#888888', defs: '' },
1904    article: { bg: '#fafafa', fg: '#111827', accent: '#f59e0b', muted: '#6b7280', defs: '' },
1905    profile: { bg: '#ffffff', fg: '#0f172a', accent: '#22c55e', muted: '#64748b', defs: '' },
1906  }
1907  const themeObj = themes[theme] || themes.gradient
1908  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
1909  function wrap(text, max) {
1910    const words = text.split(' '); const lines = []; let cur = ''
1911    for (const w of words) { if ((cur+' '+w).trim().length <= max) { cur=(cur+' '+w).trim() } else { if(cur)lines.push(cur); cur=w } }
1912    if (cur) lines.push(cur); return lines.slice(0,3)
1913  }
1914  const titleLines = wrap(title || 'Untitled', 28)
1915
1916  const wordCount = subtitle ? subtitle.split(' ').length + title.split(' ').length : title.split(' ').length
1917  const readingTime = Math.max(1, Math.ceil(wordCount / 200))
1918
1919  const responsiveBase = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 ${WIDTH} ${HEIGHT}" preserveAspectRatio="xMidYMid meet">`
1920
1921  if (theme === 'code') {
1922    const fontSize = titleLines.length > 1 ? (titleLines.length > 2 ? 60 : 70) : 80
1923    const titleY = titleLines.length === 1 ? 260 : titleLines.length === 2 ? 220 : 180
1924    const titleSvg = titleLines.map((line, i) => `<text x="120" y="${titleY + i * (fontSize + 12)}" font-family="monospace" font-size="${fontSize}" font-weight="600" fill="${themeObj.fg}">${esc(line)}</text>`).join('\n')
1925    const subSvg = subtitle ? `<text x="120" y="${titleY + titleLines.length * (fontSize + 12) + 30}" font-family="monospace" font-size="28" fill="${themeObj.muted}">${esc(subtitle.slice(0, 80))}</text>` : ''
1926    const dots = `<circle cx="60" cy="60" r="8" fill="#ff5f56"/><circle cx="90" cy="60" r="8" fill="#ffbd2e"/><circle cx="120" cy="60" r="8" fill="#27c93f"/>`
1927    const pillText = author ? esc(author) : 'package'
1928    const pill = `<rect x="120" y="100" width="${pillText.length * 14 + 40}" height="40" rx="20" fill="${themeObj.accent}" opacity="0.2"/><text x="${120 + (pillText.length * 14 + 40) / 2}" y="126" font-family="monospace" font-size="18" fill="${themeObj.accent}" font-weight="bold" text-anchor="middle">${pillText}</text>`
1929    const footer = title && !title.includes(' ') && title.length < 30 ? `<rect x="120" y="${HEIGHT - 100}" width="400" height="60" rx="6" fill="#1e293b"/><text x="140" y="${HEIGHT - 62}" font-family="monospace" font-size="22" fill="#a1a1aa">$ npm install ${esc(title.toLowerCase())}</text>` : ''
1930    return `${responsiveBase}${themeObj.defs}<rect width="${WIDTH}" height="${HEIGHT}" fill="${themeObj.bg}"/><circle cx="${WIDTH - 150}" cy="${HEIGHT - 100}" r="250" fill="${themeObj.accent}" filter="url(#glow)" opacity="0.15"/>${dots}${pill}${titleSvg}${subSvg}${footer}</svg>`
1931  } else if (theme === 'article') {
1932    const fontSize = titleLines.length > 1 ? (titleLines.length > 2 ? 68 : 80) : 96
1933    const titleY = titleLines.length === 1 ? 280 : titleLines.length === 2 ? 240 : 200
1934    const titleSvg = titleLines.map((line, i) => `<text x="120" y="${titleY + i * (fontSize + 12)}" font-family="Georgia, serif" font-size="${fontSize}" font-weight="bold" fill="${themeObj.fg}">${esc(line)}</text>`).join('\n')
1935    const subSvg = subtitle ? `<text x="120" y="${titleY + titleLines.length * (fontSize + 12) + 40}" font-family="Georgia, serif" font-size="36" font-style="italic" fill="${themeObj.muted}">${esc(subtitle.slice(0, 80))}</text>` : ''
1936    const quoteMark = `<text x="70" y="240" font-family="Georgia, serif" font-size="240" fill="${themeObj.accent}" opacity="0.1" font-weight="bold">“</text>`
1937    const leftBar = `<rect x="0" y="0" width="16" height="${HEIGHT}" fill="${themeObj.accent}"/>`
1938    const authorSvg = author ? `<text x="120" y="${HEIGHT - 80}" font-family="system-ui, sans-serif" font-size="24" fill="${themeObj.fg}" font-weight="600">${esc(author)}</text>` : ''
1939    const readingTimeSvg = `<text x="${author ? 120 + author.length * 15 + 40 : 120}" y="${HEIGHT - 80}" font-family="system-ui, sans-serif" font-size="24" fill="${themeObj.muted}">· ${readingTime} min read</text>`
1940    return `${responsiveBase}<rect width="${WIDTH}" height="${HEIGHT}" fill="${themeObj.bg}"/>${leftBar}${quoteMark}<text x="120" y="100" font-family="system-ui, sans-serif" font-size="20" letter-spacing="2" font-weight="bold" text-transform="uppercase" fill="${themeObj.accent}">ARTICLE</text>${titleSvg}${subSvg}${authorSvg}${readingTimeSvg}</svg>`
1941  } else if (theme === 'profile') {
1942    const initials = title ? title.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() : '👤'
1943    const avatar = `<circle cx="${WIDTH / 2}" cy="220" r="100" fill="#22c55e" opacity="0.1"/><circle cx="${WIDTH / 2}" cy="220" r="90" fill="#22c55e" opacity="0.2"/><text x="${WIDTH / 2}" y="250" font-family="system-ui, sans-serif" font-size="80" font-weight="bold" fill="#22c55e" text-anchor="middle">
1943${esc(initials)}</text>`
1944    const titleSvg = `<text x="${WIDTH / 2}" y="400" font-family="system-ui, sans-serif" font-size="70" font-weight="bold" fill="${themeObj.fg}" text-anchor="middle" letter-spacing="-1">${esc(title || 'Unknown User')}</text>`
1945    const subSvg = subtitle ? `<text x="${WIDTH / 2}" y="460" font-family="system-ui, sans-serif" font-size="32" fill="${themeObj.muted}" text-anchor="middle">${esc(subtitle.slice(0, 80))}</text>` : ''
1946    const handleSvg = author ? `<text x="${WIDTH / 2 - 20}" y="530" font-family="monospace" font-size="24" fill="#22c55e" text-anchor="end">@${esc(author)}</text><rect x="${WIDTH / 2}" y="510" width="2" height="24" fill="${themeObj.muted}" opacity="0.3"/><text x="${WIDTH / 2 + 20}" y="530" font-family="system-ui, sans-serif" font-size="22" fill="${themeObj.muted}" text-anchor="start" font-weight="bold" letter-spacing="3">PROFILE</text>` : ''
1947    const topBar = `<rect x="0" y="0" width="${WIDTH}" height="8" fill="#22c55e"/>`
1948    return `${responsiveBase}<rect width="${WIDTH}" height="${HEIGHT}" fill="${themeObj.bg}"/>${topBar}${avatar}${titleSvg}${subSvg}${handleSvg}</svg>`
1949  }
1950
1951  const fontSize = titleLines.length > 2 ? 68 : titleLines.length > 1 ? 78 : 88
1952  const ty = titleLines.length === 1 ? 290 : titleLines.length === 2 ? 260 : 230
1953  const tsvg = titleLines.map((l,i) => `<text x="80" y="${ty+i*(fontSize+8)}" font-family="system-ui,sans-serif" font-size="${fontSize}" font-weight="800" fill="${themeObj.fg}" letter-spacing="-2">${esc(l)}</text>`).join('')
1954  const ssvg = subtitle ? `<text x="80" y="${ty+titleLines.length*(fontSize+8)+28}" font-family="system-ui,sans-serif" font-size="32" fill="${themeObj.muted}">${esc(subtitle.slice(0,80))}</text>` : ''
1955  const asvg = author ? `<text x="80" y="${HEIGHT-60}" font-family="system-ui,sans-serif" font-size="26" fill="${themeObj.muted}">✦ ${esc(author.slice(0,60))}</text>` : ''
1956  return `${responsiveBase}${themeObj.defs||''}<rect width="${WIDTH}" height="${HEIGHT}" fill="${themeObj.bg}"/><circle cx="${WIDTH-180}" cy="${HEIGHT/2}" r="220" fill="${themeObj.accent}" opacity="0.07"/><circle cx="${WIDTH-80}" cy="80" r="120" fill="${themeObj.accent}" opacity="0.05"/><rect x="0" y="60" width="6" height="${HEIGHT-120}" fill="${themeObj.accent}" rx="3"/>${tsvg}${ssvg}${asvg}<rect x="0" y="${HEIGHT-8}" width="${WIDTH}" height="8" fill="${themeObj.accent}" opacity="0.8"/></svg>`
1957}
1958
1959async function createHmacInBrowser(data, secret) {
1960  const enc = new TextEncoder();
1961  const key = await crypto.subtle.importKey(
1962    'raw', enc.encode(secret),
1963    { name: 'HMAC', hash: 'SHA-256' },
1964    false, ['sign']
1965  );
1966  const signature = await crypto.subtle.sign('HMAC', key, enc.encode(data));
1967  return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
1968}
1969
1970function togglePreviewGroup(checkId, groupId) {
1971  const check = document.getElementById(checkId);
1972  const group = document.getElementById(groupId);
1973  const wrapper = document.getElementById(groupId + '-wrapper');
1974  
1975  if (check && group) {
1976    if (check.checked) {
1977      group.classList.remove('hidden');
1978      if (wrapper) wrapper.classList.add('open');
1979    } else {
1980      if (wrapper) wrapper.classList.remove('open');
1981      // Delay hiding the group to allow animation to complete
1982      setTimeout(() => {
1983        if (!check.checked) group.classList.add('hidden');
1984      }, 300);
1985    }
1986  }
1987  
1988  const refreshBtn = document.getElementById('refresh-real-btn');
1989  const enableDynamic = document.getElementById('check-enable-dynamic')?.checked;
1990  if (refreshBtn) {
1991    if (enableDynamic) refreshBtn.classList.remove('hidden');
1992    else refreshBtn.classList.add('hidden');
1993  }
1994
1995  updateDashPreview();
1996}
1997
1998async function updateDashPreview() {
1999  const title = document.getElementById('dash-title').value || 'Hello, World!'
2000  const subtitle = document.getElementById('dash-subtitle').value || ''
2001  const author = document.getElementById('dash-author').value || ''
2002  const theme = document.getElementById('dash-theme').value || 'gradient'
2003  
2004  // Reset real preview when inputs change (back to SVG)
2005  const realImg = document.getElementById('real-preview-img');
2006  if (realImg) realImg.style.display = 'none';
2007
2008  const enableDynamic = document.getElementById('check-enable-dynamic')?.checked;
2009  const enableSigning = document.getElementById('check-enable-signing')?.checked;
2010  
2011  const dataUrl = enableDynamic ? (document.getElementById('dash-data-url').value || '') : '';
2012  const keyEl = document.getElementById('dash-key-select');
2013  const sigBadge = document.getElementById('sig-status-badge');
2014  
2015  let selectedKey = 'YOUR_API_KEY'
2016  let secret = ''
2017  
2018  if (enableSigning && keyEl && keyEl.value !== 'YOUR_API_KEY') {
2019    const opt = keyEl.options[keyEl.selectedIndex]
2020    selectedKey = opt.value
2021    secret = opt.dataset.secret || ''
2022  }
2023
2024  // fast preview (SVG) — custom themes use the /preview API endpoint
2025  const previewEl = document.getElementById('dash-og-preview')
2026  if (theme.startsWith('custom_')) {
2027    const previewParams = new URLSearchParams({ title, subtitle, author, theme })
2028    try {
2029      const svgRes = await fetch(`${API}/api/preview?${previewParams}`, {
2030        headers: token ? { Authorization: `Bearer ${token}` } : {}
2031      })
2032      if (svgRes.ok) {
2033        let svgText = await svgRes.text()
2034        // Make SVG fill the container (server SVG uses fixed 1200x630 dimensions)
2035        svgText = svgText.replace(/<svg([^>]*)width="1200"([^>]*)height="630"/, '<svg$1width="100%"$2height="100%"')
2036          .replace(/<svg([^>]*)height="630"([^>]*)width="1200"/, '<svg$1height="100%"$2width="100%"')
2037        if (!svgText.includes('viewBox')) {
2038          svgText = svgText.replace('<svg', '<svg viewBox="0 0 1200 630" preserveAspectRatio="xMidYMid meet"')
2039        }
2040        previewEl.innerHTML = svgText
2041      } else {
2042        previewEl.innerHTML = renderSvg({ title, subtitle, author, theme: 'gradient' })
2043      }
2044    } catch (_) {
2045      previewEl.innerHTML = renderSvg({ title, subtitle, author, theme: 'gradient' })
2046    }
2047  } else {
2048    previewEl.innerHTML = renderSvg({ title, subtitle, author, theme })
2049  }
2050
2051  // construct params
2052  const paramsObj = { 
2053    key: selectedKey, 
2054    title, 
2055    ...(subtitle && { subtitle }), 
2056    ...(author && { author }), 
2057    theme,
2058    ...(dataUrl && { data_url: dataUrl })
2059  }
2060
2061  const params = new URLSearchParams(paramsObj)
2062  const baseUrl = 'https://api.metashot.io'
2063  let url = `${baseUrl}/og?${params}`
2064
2065  if (enableSigning && secret) {
2066    const keysToSign = Object.keys(paramsObj).filter(k => k !== 'key').sort()
2067    const base = keysToSign.map(k => `${k}=${paramsObj[k]}`).join('&')
2068    const sig = await createHmacInBrowser(base, secret)
2069    url += `&s=${sig}`
2070    if (sigBadge) sigBadge.classList.remove('hidden');
2071  } else if (sigBadge) {
2072    sigBadge.classList.add('hidden');
2073  }
2074
2075  document.getElementById('dash-url').textContent = url
2076  
2077  // Update Meta Tags snippet
2078  const fullImgUrl = url
2079  const metaHtml = `<!-- Open Graph / Facebook -->
2080<meta property="og:type" content="website">
2081<meta property="og:url" content="${window.location.origin}">
2082<meta property="og:title" content="${esc(title)}">
2083<meta property="og:description" content="${esc(subtitle || '...')}">
2084<meta property="og:image" content="${fullImgUrl}">
2085
2086<!-- Twitter Card -->
2087<meta name="twitter:card" content="summary_large_image">
2088<meta name="twitter:title" content="${esc(title)}">
2089<meta name="twitter:description" content="${esc(subtitle || '...')}">
2090<meta name="twitter:image" content="${fullImgUrl}">`
2091
2092  const metaEl = document.getElementById('dash-meta')
2093  if (metaEl) metaEl.textContent = metaHtml
2094}
2095
2096function copyDashSnippet() {
2097  const activeTab = document.querySelector('.endpoint-tab.active')
2098  const targetId = activeTab?.dataset.target
2099  const el = document.getElementById(targetId === 'url-view' ? 'dash-url' : 'dash-meta')
2100  if (!el) return
2101  
2102  let text = el.textContent
2103  // If it's already a full URL (which it is now for api.metashot.io), don't prepend origin
2104  if (targetId === 'url-view' && !text.startsWith('http')) {
2105    text = window.location.origin + text
2106  }
2107
2108  navigator.clipboard.writeText(text).then(() => {
2109    const btn = document.getElementById('dash-copy-btn');
2110    const original = btn.innerHTML;
2111    btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> <span style="font-size:0.6rem">COPIED</span>`;
2112    btn.style.color = "#4ade80";
2113    setTimeout(() => {
2114      btn.innerHTML = original;
2115      btn.style.color = "";
2116    }, 2000);
2117  })
2118}
2119
2120function populateKeySelect() {
2121  const sel = document.getElementById('dash-key-select')
2122  if (sel) {
2123    sel.innerHTML = '<option value="YOUR_API_KEY">Default (No key selected)</option>' +
2124      keys.filter(k => k.is_active).map(k => {
2125        const fullKey = k.key_prefix + '****************'
2126        return `<option value="${fullKey}" data-secret="${k.signing_secret}">${esc(k.name)} (${esc(k.key_prefix)}…)</option>`
2127      }).join('')
2128  }
2129  updateDashPreview()
2130}
2131
2132async function refreshRealPreview() {
2133  const urlDisplay = document.getElementById('dash-url').textContent;
2134  const img = document.getElementById('real-preview-img');
2135  const loader = document.getElementById('real-preview-loader');
2136  const placeholder = document.getElementById('real-preview-placeholder');
2137  const checkDynamic = document.getElementById('check-enable-dynamic');
2138  const dataUrlInput = document.getElementById('dash-data-url');
2139
2140  if (!urlDisplay || !img) return;
2141
2142  // Validation: If dynamic data is enabled, a URL must be provided.
2143  if (checkDynamic && checkDynamic.checked) {
2144    if (!dataUrlInput.value.trim()) {
2145      dataUrlInput.style.borderColor = 'var(--error)';
2146      dataUrlInput.focus();
2147      if (placeholder) {
2148        placeholder.style.display = 'flex';
2149        placeholder.classList.remove('hidden');
2150        placeholder.innerHTML = `<p style="color:var(--danger); font-size: 0.8rem; font-weight: 600;">Dynamic Data URL Required</p>`;
2151      }
2152      return;
2153    }
2154    dataUrlInput.style.borderColor = 'var(--border)';
2155  }
2156
2157  if (loader) loader.classList.remove('hidden');
2158  if (img) img.style.display = 'none';
2159  if (placeholder) {
2160    placeholder.style.display = 'none';
2161    placeholder.classList.add('hidden');
2162  }
2163
2164  try {
2165    const baseUrl = window.location.origin;
2166    const fullSourceUrl = new URL(urlDisplay, baseUrl);
2167    fullSourceUrl.pathname = '/api/preview';
2168    const previewUrl = fullSourceUrl.toString();
2169    
2170    const res = await fetch(previewUrl, {
2171      headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }
2172    });
2173
2174    if (!res.ok) throw new Error(`Render failed: ${res.status}`);
2175
2176    const blob = await res.blob();
2177    const objectUrl = URL.createObjectURL(blob);
2178
2179    if (img.dataset.prevUrl) {
2180      URL.revokeObjectURL(img.dataset.prevUrl);
2181    }
2182    img.dataset.prevUrl = objectUrl;
2183
2184    img.onload = () => {
2185      if (loader) loader.classList.add('hidden');
2186      img.style.display = 'block';
2187    };
2188    img.src = objectUrl;
2189  } catch (e) {
2190    console.error('Preview error:', e);
2191    if (loader) loader.classList.add('hidden');
2192    if (placeholder) {
2193      placeholder.style.display = 'flex';
2194      placeholder.classList.remove('hidden');
2195      placeholder.innerHTML = `<p style="color:var(--danger); font-size: 0.75rem; line-height: 1.4;"> Render failed.<br/><span style="opacity: 0.7; font-size: 0.7rem;">Check your data URL or try again.</span></p>`;
2196    }
2197  }
2198}
2199
2200
2201function initEndpointTabs() {
2202  document.querySelectorAll('.endpoint-tab').forEach(tab => {
2203    tab.onclick = () => {
2204      document.querySelectorAll('.endpoint-tab').forEach(t => t.classList.remove('active'))
2205      document.querySelectorAll('.endpoint-view').forEach(v => v.classList.add('hidden'))
2206      
2207      tab.classList.add('active')
2208      const target = document.getElementById(tab.dataset.target)
2209      if (target) target.classList.remove('hidden')
2210    }
2211  })
2212}
2213
2214// ─── Usage / Analytics ───────────────────────────────────────────────────────
2215
2216let usageStats = []
2217
2218async function loadUsageStats() {
2219  const { ok, data } = await api('/api/usage')
2220  if (ok) {
2221    usageStats = data
2222  }
2223  renderUsage()
2224}
2225
2226function renderUsage() {
2227  const cards = document.getElementById('usage-cards')
2228  if (!cards) return
2229  
2230  const isPro = ['pro', 'scale'].includes(user?.plan)
2231  const totalLimit = user?.plan === 'scale' ? 250000 : user?.plan === 'pro' ? 50000 : 1000
2232  const totalUsed = keys.reduce((s, k) => s + (k.requests_used || 0), 0)
2233  const pct = totalLimit ? Math.min(100, Math.round((totalUsed / totalLimit) * 100)) : 0
2234  const activeKeys = keys.filter(k => k.is_active).length
2235
2236  let chartHtml = ''
2237  if (usageStats && usageStats.length > 0) {
2238    const maxReq = Math.max(...usageStats.map(d => d.total_requests || 0), 10)
2239    
2240    chartHtml = `
2241      <div class="usage-chart-wrap" style="grid-column: span 2; background: var(--bg-card); border: 1px solid var(--border); padding: 24px; border-radius: 12px; margin-top: 16px;">
2242        <h4 style="margin: 0 0 24px 0; color: var(--muted); font-size: 0.9rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Requests (Last 30 Days)</h4>
2243        <div class="usage-chart" style="height: 200px; display: flex; align-items: flex-end; gap: 4px; padding-bottom: 20px;">
2244          ${usageStats.map((d, i) => {
2245            const h = Math.round((d.total_requests / maxReq) * 100)
2246            const ch = d.total_requests ? Math.round((d.cache_hits / d.total_requests) * 100) : 0
2247            return `
2248              <div style="flex: 1; height: 100%; display: flex; flex-direction: column-reverse; position: relative;" title="${d.date}: ${d.total_requests} reqs (${d.cache_hits} hits)">
2249                <div style="height: ${h}%; background: var(--accent); opacity: 0.6; border-radius: 2px 2px 0 0; position: relative;">
2250                  <div style="position: absolute; bottom: 0; left: 0; right: 0; height: ${ch}%; background: var(--success); opacity: 0.4; border-radius: 2px 2px 0 0;"></div>
2251                </div>
2252              </div>
2253            `
2254          }).join('')}
2255        </div>
2256        <div style="display:flex; justify-content:space-between; margin-top:4px; font-size: 0.7rem; color: var(--muted); font-family: var(--font-mono);">
2257          <span>${usageStats[0].date}</span>
2258          <span>${usageStats[usageStats.length-1].date}</span>
2259        </div>
2260        <div style="display:flex; gap:16px; margin-top:20px; font-size:0.8rem;">
2261          <div style="display:flex; align-items:center; gap:6px;"><span style="width:10px; height:10px; background:var(--accent); border-radius:2px; opacity:0.6;"></span> <span class="muted">Requests</span></div>
2262          <div style="display:flex; align-items:center; gap:6px;"><span style="width:10px; height:10px; background:var(--success); border-radius:2px; opacity:0.4;"></span> <span class="muted">Cache Hits</span></div>
2263        </div>
2264      </div>
2265    `
2266  } else {
2267    chartHtml = `<div class="usage-chart-wrap" style="grid-column: span 2; background: var(--bg-card); border: 1px solid var(--border); padding: 48px; border-radius: 12px; text-align: center; color: var(--muted);">No usage data recorded yet.</div>`
2268  }
2269
2270  cards.innerHTML = `
2271    <div class="usage-card">
2272      <svg class="usage-card-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
2273      <div class="usage-card-label">Requests Used</div>
2274      <div class="usage-card-value">${totalUsed.toLocaleString()}</div>
2275      <div class="usage-card-sub">of ${totalLimit.toLocaleString()} this month</div>
2276    </div>
2277    <div class="usage-card">
2278      <svg class="usage-card-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"></path><path d="M22 12A10 10 0 0 0 12 2v10z"></path></svg>
2279      <div class="usage-card-label">Quota Used</div>
2280      <div class="usage-card-value">${pct}%</div>
2281      <div class="usage-card-sub">resets on the 1st</div>
2282    </div>
2283    <div class="usage-card">
2284      <svg class="usage-card-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
2285      <div class="usage-card-label">Active Keys</div>
2286      <div class="usage-card-value">${activeKeys}</div>
2287      <div class="usage-card-sub">of ${isPro ? 20 : 2} allowed</div>
2288    </div>
2289    <div class="usage-card" style="position:relative; overflow:hidden;">
2290      <svg class="usage-card-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
2291      <div class="usage-card-label">Plan</div>
2292      <div class="usage-card-value" style="font-size:1.4rem;text-transform:capitalize">${user?.plan || 'free'}</div>
2293      <div class="usage-card-sub">
2294        ${(isPro && user?.cancel_at_period_end) ? (user?.plan_expires_at ? `Expiring ${new Date(user.plan_expires_at).toLocaleDateString()}` : 'Expiring soon') : 
2295          (isPro && user?.plan_expires_at) ? `Renews ${new Date(user.plan_expires_at).toLocaleDateString()}` : 'current tier'}
2296      </div>
2297      ${user?.is_admin ? '<div style="position:absolute; top:8px; right:8px; font-size:0.6rem; background:var(--accent); color:white; padding:2px 6px; border-radius:4px; font-weight:bold;">ADMIN</div>' : ''}
2298    </div>
2299    ${chartHtml}
2300  `
2301}
2302
2303// ─── Admin ───────────────────────────────────────────────────────────────────
2304
2305async function loadAdminData() {
2306  console.log('Loading admin data…');
2307  try {
2308    const [statsRes, usersRes] = await Promise.all([
2309      api('/api/admin/stats'),
2310      api('/api/admin/users')
2311    ]);
2312    
2313    if (statsRes.ok) {
2314        console.log('Admin stats loaded:', statsRes.data);
2315        renderAdminStats(statsRes.data);
2316    } else {
2317        console.error('Failed to load admin stats:', statsRes.data);
2318    }
2319
2320    if (usersRes.ok) {
2321        console.log('Admin users loaded:', usersRes.data);
2322        renderAdminUsers(usersRes.data);
2323    } else {
2324        console.error('Failed to load admin users:', usersRes.data);
2325    }
2326  } catch (err) {
2327    console.error('CRITICAL: Admin data fetch failed:', err);
2328  }
2329}
2330window.loadAdminData = loadAdminData;
2331
2332function renderAdminStats(s) {
2333  const el = document.getElementById('admin-stats-cards')
2334  if (!el) return
2335  el.innerHTML = `
2336    <div class="usage-card">
2337      <div class="usage-card-label">Total Users</div>
2338      <div class="usage-card-value">
2338${s.total_users.toLocaleString()}</div>
2339    </div>
2340    <div class="usage-card">
2341      <div class="usage-card-label">Total API Keys</div>
2342      <div class="usage-card-value">${s.total_keys.toLocaleString()}</div>
2343    </div>
2344    <div class="usage-card">
2345      <div class="usage-card-label">Total Requests</div>
2346      <div class="usage-card-value">${s.total_requests.toLocaleString()}</div>
2347    </div>
2348    <div class="usage-card">
2349      <div class="usage-card-label">Cache Hit Rate</div>
2350      <div class="usage-card-value">${s.total_requests ? Math.round((s.total_cache_hits/s.total_requests)*100) : 0}%</div>
2351    </div>
2352  `
2353}
2354
2355function renderAdminUsers(usersList) {
2356  const el = document.getElementById('admin-users-list')
2357  if (!el) return
2358  if (!usersList.length) { el.innerHTML = '<div class="empty-state">No users found.</div>'; return }
2359  
2360  el.innerHTML = usersList.map(u => `
2361    <div class="key-card">
2362      <div class="key-info">
2363        <div class="key-name">${esc(u.email)} ${u.is_admin ? '<span class="badge">ADMIN</span>' : ''}</div>
2364        <div class="key-meta">
2365          <span>ID: ${u.id}</span>
2366          <span>Tier: <strong style="text-transform:capitalize">${u.plan}</strong></span>
2367          <span>Joined ${new Date(u.created_at * 1000).toLocaleDateString()}</span>
2368        </div>
2369      </div>
2370      <div style="display:flex; gap:12px;">
2371        <select onchange="adminUpdatePlan('${u.id}', this.value)" style="background:var(--bg-card); color:var(--text); border:1px solid var(--border); padding:4px 8px; border-radius:4px; font-size:0.8rem;">
2372          <option value="free" ${u.plan==='free'?'selected':''}>Free</option>
2373          <option value="pro" ${u.plan==='pro'?'selected':''}>Pro</option>
2374          <option value="enterprise" ${u.plan==='enterprise'?'selected':''}>Enterprise</option>
2375        </select>
2376        <button class="btn-sm" onclick="adminToggleAdmin('${u.id}', ${!u.is_admin})">
2377          ${u.is_admin ? 'Demote' : 'Make Admin'}
2378        </button>
2379      </div>
2380    </div>
2381  `).join('')
2382}
2383
2384async function adminUpdatePlan(userId, plan) {
2385  console.log('Updating plan for', userId, 'to', plan);
2386  const { ok, data } = await api(`/api/admin/users/${userId}/plan`, {
2387    method: 'POST',
2388    body: JSON.stringify({ plan })
2389  });
2390  if (ok) {
2391    loadAdminData();
2392  } else {
2393    alert('Failed to update plan: ' + (data.error || 'Unknown error'));
2394  }
2395}
2396window.adminUpdatePlan = adminUpdatePlan;
2397
2398async function adminToggleAdmin(userId, isAdmin) {
2399  console.log('Toggling admin for', userId, 'to', isAdmin);
2400  const action = isAdmin ? 'promote to admin' : 'demote to user';
2401  const confirmed = await customConfirm(`${isAdmin ? 'Promote' : 'Demote'} User`, `Are you sure you want to ${action}?`, isAdmin ? 'Promote User' : 'Demote User', !isAdmin);
2402  if (!confirmed) return;
2403  const { ok, data } = await api(`/api/admin/users/${userId}/admin`, {
2404    method: 'POST',
2405    body: JSON.stringify({ isAdmin })
2406  });
2407  if (ok) {
2408    if (userId === user.id) location.reload(); // if self-demoted/promoted
2409    else loadAdminData();
2410  } else {
2411    alert('Failed to toggle admin: ' + (data.error || 'Unknown error'));
2412  }
2413}
2414window.adminToggleAdmin = adminToggleAdmin;
2415
2416// ─── Utils ────────────────────────────────────────────────────────────────────
2417
2418function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
2419function timeAgo(ts) {
2420  const diff = Math.floor((Date.now() / 1000) - ts)
2421  if (diff < 60) return 'just now'
2422  if (diff < 3600) return `${Math.floor(diff/60)}m ago`
2423  if (diff < 86400) return `${Math.floor(diff/3600)}h ago`
2424  return `${Math.floor(diff/86400)}d ago`
2425}
2426
2427// ─── Init ─────────────────────────────────────────────────────────────────────
2428
2429document.addEventListener('DOMContentLoaded', async () => {
2430  // Setup custom confirm button listener
2431  const confirmBtn = document.getElementById('confirm-btn');
2432  if (confirmBtn) {
2433    confirmBtn.addEventListener('click', () => closeConfirmModal(true));
2434  }
2435
2436  // Live preview listeners
2437  ['dash-title','dash-subtitle','dash-author','dash-data-url'].forEach(id => {
2438    document.getElementById(id)?.addEventListener('input', updateDashPreview)
2439  })
2440  document.getElementById('dash-theme')?.addEventListener('change', updateDashPreview)
2441  document.getElementById('dash-key-select')?.addEventListener('change', updateDashPreview)
2442  document.getElementById('check-enable-signing')?.addEventListener('change', updateDashPreview)
2443  document.getElementById('check-enable-dynamic')?.addEventListener('change', updateDashPreview)
2444  
2445  const params = new URLSearchParams(window.location.search)
2446  const action = params.get('action')
2447  const urlToken = params.get('token')
2448  
2449  if (action === 'reset' && urlToken) {
2450    hideLoader()
2451    showReset()
2452    return
2453  }
2454  if (action === 'verify' && urlToken) {
2455    hideLoader()
2456    verifyEmailAuto(urlToken)
2457    return
2458  }
2459
2460  if (token) {
2461    if (user) {
2462      // Cached user — show dashboard instantly, refresh in background
2463      await showApp()
2464      api('/auth/me').then(({ ok, data }) => {
2465        if (!ok) {
2466          // Token revoked or expired — silently log out
2467          logout()
2468          showLogin()
2469        } else {
2470          user = data
2471          localStorage.setItem('og_user', JSON.stringify(data))
2472          renderPlanBox()
2473        }
2474      })
2475    } else {
2476      // No cache — must wait for /auth/me before showing anything
2477      const { ok, data } = await api('/auth/me')
2478      if (ok) {
2479        user = data
2480        localStorage.setItem('og_user', JSON.stringify(data))
2481        await showApp()
2482      } else {
2483        localStorage.removeItem('og_token')
2484        token = null
2485        hideLoader()
2486        showLogin()
2487      }
2488    }
2489  } else {
2490    hideLoader()
2491    showLogin()
2492  }
2493})
2494
2495// ─── User Menu ────────────────────────────────────────────────────────────────
2496
2497function toggleUserMenu() {
2498  const dropdown = document.getElementById('user-dropdown')
2499  if (dropdown) dropdown.classList.toggle('hidden')
2500}
2501
2502function showRegisterSuccess() {
2503  hideAllAuthForms()
2504  document.getElementById('register-success').classList.remove('hidden')
2505  document.getElementById('auth-tabs').classList.add('hidden')
2506}
2507
2508async function resendVerification() {
2509  const btn = document.getElementById('resend-btn')
2510  const emailEl = document.getElementById('sent-email-display')
2511  const email = emailEl?.textContent || user?.email
2512  if (!email || email === 'your email') return
2513
2514  const original = btn.textContent
2515  btn.textContent = 'Sending...'
2516  btn.disabled = true
2517
2518  try {
2519    const { ok } = await api('/auth/resend-verification', {
2520      method: 'POST',
2521      body: JSON.stringify({ email })
2522    })
2523    if (ok) {
2524      showToast('Verification link sent again!', 'success')
2525      btn.textContent = 'Sent!'
2526      setTimeout(() => {
2527        btn.textContent = original
2528        btn.disabled = false
2529      }, 5000)
2530    } else {
2531      showToast('Failed to resend. Please try again.', 'error')
2532      btn.textContent = original
2533      btn.disabled = false
2534    }
2535  } catch (e) {
2536    btn.textContent = original
2537    btn.disabled = false
2538  }
2539}
2540
2541// ─── Images ───────────────────────────────────────────────────────────────────
2542
2543let imagesPage = 1
2544let imagesTotal = 0
2545let imagesSearchTimer = null
2546let imagesFavOnly = false
2547
2548function debounceImageSearch() {
2549  clearTimeout(imagesSearchTimer)
2550  imagesSearchTimer = setTimeout(() => loadImages(1), 350)
2551}
2552
2553function toggleImagesFavFilter() {
2554  imagesFavOnly = !imagesFavOnly
2555  document.getElementById('images-fav-btn')?.classList.toggle('active', imagesFavOnly)
2556  loadImages(1)
2557}
2558window.toggleImagesFavFilter = toggleImagesFavFilter
2559
2560async function loadImages(page = 1) {
2561  imagesPage = page
2562  const grid = document.getElementById('images-grid')
2563  if (!grid) return
2564
2565  // Show skeleton cards while loading
2566  grid.innerHTML = Array(8).fill(`
2567    <div class="img-card img-card-skeleton">
2568      <div class="img-card-thumb"></div>
2569      <div class="img-card-body">
2570        <div class="img-skel-line"></div>
2571        <div class="img-skel-line short"></div>
2572      </div>
2573    </div>`).join('')
2574
2575  const q     = document.getElementById('images-search')?.value.trim() || ''
2576  const theme = document.getElementById('images-theme-filter')?.value || ''
2577  const params = new URLSearchParams({ page, limit: 20 })
2578  if (q) params.set('q', q)
2579  if (theme) params.set('theme', theme)
2580  if (imagesFavOnly) params.set('favorited', '1')
2581
2582  const { ok, data } = await api(`/api/images?${params}`)
2583  if (!ok) { grid.innerHTML = '<p style="color:var(--muted);grid-column:1/-1;padding:40px;text-align:center;">Failed to load images.</p>'; return }
2584
2585  imagesTotal = data.total || 0
2586  const images = data.images || []
2587  const isPro = ['pro', 'scale'].includes(user?.plan)
2588  const planLimit = user?.plan === 'scale' ? 5000 : user?.plan === 'pro' ? 500 : 50
2589
2590  // Update summary bar
2591  const bar = document.getElementById('images-summary-bar')
2592  if (bar) {
2593    bar.style.display = imagesTotal > 0 ? 'flex' : 'none'
2594    document.getElementById('img-stat-total').textContent = imagesTotal.toLocaleString()
2595    document.getElementById('img-stat-limit').textContent = planLimit.toLocaleString()
2596    const pct = Math.min(100, Math.round((imagesTotal / planLimit) * 100))
2597    const barEl = document.getElementById('img-stat-bar')
2598    if (barEl) { barEl.style.width = `${pct}%`; barEl.style.background = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : 'var(--accent)' }
2599    const pctEl = document.getElementById('img-stat-pct')
2600    if (pctEl) pctEl.textContent = `${pct}%`
2601  }
2602
2603  if (!images.length) {
2604    const msg = imagesFavOnly ? 'No favorites yet — click ☆ on any image to save it.' : 'Images are tracked automatically when you generate them via the API.'
2605    grid.innerHTML = `
2606      <div style="grid-column:1/-1;text-align:center;padding:72px 20px;color:var(--muted);">
2607        <div style="font-size:2.8rem;margin-bottom:14px;">🖼️</div>
2608        <div style="font-size:1rem;font-weight:600;color:var(--fg);margin-bottom:8px;">${imagesFavOnly ? 'No favorites' : 'No images yet'}</div>
2609        <div style="font-size:0.85rem;max-width:320px;margin:0 auto;line-height:1.6;">${msg}</div>
2610      </div>`
2611    updateImagesPagination(0, 0)
2612    return
2613  }
2614
2615  grid.innerHTML = images.map(img => renderImageCard(img, isPro)).join('')
2616  updateImagesPagination(page, data.total)
2617  loadImageThumbnails()
2618}
2619
2620async function loadImageThumbnails() {
2621  const imgs = document.querySelectorAll('#images-grid img[data-image-id]')
2622  await Promise.all(Array.from(imgs).map(async (imgEl) => {
2623    const id = imgEl.dataset.imageId
2624    try {
2625      const res = await fetch(`${API}/api/images/${id}/thumbnail`, {
2626        headers: { Authorization: `Bearer ${token}` }
2627      })
2628      if (!res.ok) throw new Error('not found')
2629      const blob = await res.blob()
2630      imgEl.src = URL.createObjectURL(blob)
2631    } catch {
2632      imgEl.style.display = 'none'
2633      imgEl.nextElementSibling.style.display = 'flex'
2634    }
2635  }))
2636}
2637
2638function renderImageCard(img, isPro) {
2639  const date = new Date(img.created_at * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
2640  const badgeColors = {
2641    gradient: '#a78bfa', dark: '#38bdf8', light: '#6366f1', code: '#22d3ee',
2642    minimal: '#94a3b8', article: '#f59e0b', profile: '#22c55e'
2643  }
2644  const bc = badgeColors[img.theme] || '#a78bfa'
2645  const fav = img.is_favorited
2646  const copyBtn = isPro
2647    ? `<button class="img-ov-btn" onclick="event.stopPropagation();copyImageUrl('${img.id}')" title="Copy URL">🔗</button>`
2648    : `<button class="img-ov-btn" onclick="event.stopPropagation();upgradeToPro()" title="Pro feature">🔒</button>`
2649
2650  return `
2651    <div class="img-card">
2652      <div class="img-card-thumb" onclick="openImagePreview('${img.id}')">
2653        <img data-image-id="${img.id}" alt="${esc(img.title)}" style="width:100%;height:100%;object-fit:cover;display:block;"/>
2654        <div class="img-card-fallback"><span style="font-size:1.5rem;">🖼️</span><span>Cache expired</span></div>
2655        <div class="img-card-badge" style="background:${bc}22;color:${bc};border:1px solid ${bc}44;">${img.theme}</div>
2656        <div class="img-card-overlay">
2657          <button class="img-ov-btn" onclick="event.stopPropagation();openImagePreview('${img.id}')" title="Preview">👁</button>
2658          ${copyBtn}
2659          <button class="img-ov-btn danger" onclick="event.stopPropagation();deleteImage('${img.id}')" title="Delete">🗑</button>
2660        </div>
2661      </div>
2662      <div class="img-card-body">
2663        <div class="img-card-title" title="${esc(img.title)}">${esc(img.title) || '<em style="color:var(--muted)">Untitled</em>'}</div>
2664        ${img.subtitle ? `<div class="img-card-sub">${esc(img.subtitle)}</div>` : ''}
2665      </div>
2666      <div class="img-card-footer">
2667        <span class="img-card-date">${date}</span>
2668        <button class="img-card-fav" id="fav-${img.id}" style="color:${fav ? '#fbbf24' : 'var(--muted)'};"
2669          title="${fav ? 'Unfavorite' : 'Favorite'}"
2670          onclick="toggleFavorite('${img.id}', ${!fav})">${fav ? '★' : '☆'}</button>
2671      </div>
2672    </div>`
2673}
2674
2675function updateImagesPagination(page, total) {
2676  const perPage = 20
2677  const totalPages = Math.ceil(total / perPage)
2678  const info = document.getElementById('images-page-info')
2679  const prev = document.getElementById('images-prev-btn')
2680  const next = document.getElementById('images-next-btn')
2681  const pag  = document.getElementById('images-pagination')
2682  if (info) info.textContent = totalPages > 1 ? `Page ${page} of ${totalPages}` : ''
2683  if (prev) prev.disabled = page <= 1
2684  if (next) next.disabled = page >= totalPages
2685  if (pag)  pag.style.display = totalPages > 1 ? 'flex' : 'none'
2686}
2687
2688async function toggleFavorite(id, favorited) {
2689  // Optimistic update — flip icon immediately
2690  const btn = document.getElementById(`fav-${id}`)
2691  if (btn) {
2692    btn.textContent = favorited ? '★' : '☆'
2693    btn.style.color = favorited ? '#fbbf24' : 'var(--muted)'
2694    btn.title = favorited ? 'Unfavorite' : 'Favorite'
2695    btn.setAttribute('onclick', `toggleFavorite('${id}', ${!favorited})`)
2696  }
2697  const { ok } = await api(`/api/images/${id}/favorite`, {
2698    method: 'PATCH',
2699    body: JSON.stringify({ favorited })
2700  })
2701  if (!ok) loadImages(imagesPage) // revert on failure
2702}
2703
2704async function copyImageUrl(id) {
2705  const { ok, data } = await api(`/api/images/${id}/url`)
2706  if (!ok) { showToast(data.error || 'Failed to get URL', 'error'); return }
2707  await navigator.clipboard.writeText(data.params_url)
2708  showToast('URL copied to clipboard', 'success')
2709}
2710
2711async function deleteImage(id) {
2712  const confirmed = await customConfirm('Delete Image', 'Remove this image record and its cached PNG? This cannot be undone.', 'Delete', true)
2713  if (!confirmed) return
2714  const { ok } = await api(`/api/images/${id}`, { method: 'DELETE' })
2715  if (ok) { showToast('Image deleted', 'success'); loadImages(imagesPage) }
2716  else showToast('Failed to delete image', 'error')
2717}
2718
2719async function openImagePreview(id) {
2720  try {
2721    const res = await fetch(`${API}/api/images/${id}/thumbnail`, {
2722      headers: { Authorization: `Bearer ${token}` }
2723    })
2724    if (!res.ok) { showToast('Image not available', 'error'); return }
2725    const blob = await res.blob()
2726    const url = URL.createObjectURL(blob)
2727    const modal = document.getElementById('image-preview-modal')
2728    const img = document.getElementById('image-preview-img')
2729    const dl = document.getElementById('image-preview-download')
2730    if (!modal || !img) { window.open(url, '_blank'); return }
2731    img.src = url
2732    if (dl) { dl.href = url }
2733    modal.classList.remove('hidden')
2734  } catch {
2735    showToast('Failed to open image', 'error')
2736  }
2737}
2738
2739function esc(str) {
2740  return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
2741}
2742
2743document.addEventListener('click', (e) => {
2744  const wrap = document.querySelector('.user-menu-wrap')
2745  if (wrap && !wrap.contains(e.target)) {
2746    document.getElementById('user-dropdown')?.classList.add('hidden')
2747  }
2748})

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.