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_...&theme=custom_${t.id}&title=Hello+World${t.config?.data_url ? '&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);">&subtitle=...</code> 806 <code style="color:var(--accent);font-family:var(--font-mono);">&author=...</code> 807 <code style="color:var(--accent);font-family:var(--font-mono);">&data_url=https://...</code> 808 <code style="color:var(--accent);font-family:var(--font-mono);">&s=<hmac></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">· {{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">· ${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,'"')}" 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,'&').replace(/</g,'<').replace(/>/g,'>') } 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,'&').replace(/</g,'<').replace(/>/g,'>') } 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') 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.