1/** 2 * Notification Panel Component 3 * 4 * Dropdown panel shown when clicking the credits/bell icon in header. 5 * Features: 6 * - Credit balance display at top 7 * - Two tabs: Social notifications (likes, comments, follows) & Generation History 8 * - Social notifications fetched from API with click-to-navigate 9 * - Real-time unread badge from server 10 * - Click outside/ESC to close 11 * 12 * @module components/NotificationPanel 13 * @requires api/billing 14 * @created 2025-12-10 15 * @updated 2026-03-20 â Added social notifications tab 16 */ 17 18import { 19 getCachedBalanceSync, 20 refreshCreditBalance, 21 onCreditBalanceChange, 22 formatCredits, 23} from '../api/billing.js'; 24import { getAuthToken } from '../utils/auth.js'; 25import { getVideoThumbnail, isCloudinaryUrl, getOptimizedImageUrl, isR2Url, getOptimizedVideoPoster } from '../utils/media-helpers.js'; 26// CreditsModal lazy-imported on click to reduce initial bundle (~117KB savings) 27 28// ============================================================================ 29// TINY THUMBNAIL HELPER 30// ============================================================================ 31 32/** 33 * Force-optimize any image URL to a tiny thumbnail via Cloudflare CDN. 34 * Bypasses the file-extension check in getOptimizedImageUrl so extensionless 35 * R2 URLs (e.g. /generations/uuid) still get resized. 36 */ 37function tinyThumb(url, size = 48) { 38 if (!url) return ''; 39 // Already optimized 40 if (url.includes('/cdn-cgi/image/')) return url; 41 // Cloudflare Image Resizing only accepts images. If someone hands us 42 // a raw video URL (e.g. a generation_completed payload where the 43 // backend sent video_url as the thumbnail), piping it through 44 // /cdn-cgi/image/ returns HTTP 415 and spams the console. Return '' 45 // so the consumer falls back to the material-symbols icon. 46 const isVideo = /\.(mp4|webm|mov|m4v|avi|mkv)(\?|$|#)/i.test(url); 47 if (isVideo) return ''; 48 // R2 URL â build /cdn-cgi/image/ directly 49 if (isR2Url(url)) { 50 const r2Path = url.replace('https://pub-a9ded55bb45f44e7a1cd0ee6822c07e5.r2.dev/', '').replace('https://media.arturum.ai/', ''); 51 return `https://www.arturum.ai/cdn-cgi/image/width=${size},height=${size},fit=cover,quality=60,format=auto/https://media.arturum.ai/${r2Path}`; 52 } 53 // Cloudinary or other â try getOptimizedImageUrl, fallback to raw 54 return getOptimizedImageUrl(url, 'thumb', { width: size }) || url; 55} 56 57// ============================================================================ 58// STATE 59// ============================================================================ 60 61let panelElement = null; 62let isOpen = false; 63let notifications = []; // Generation history (localStorage) 64let socialNotifications = []; // Social notifications (from API) 65let socialNextCursor = null; 66let socialLoading = false; 67let unreadCount = 0; 68let prevUnreadCount = 0; // Track previous count to detect new arrivals 69let activeTab = 'social'; // 'social' | 'generation' | 'download' 70 71// Quality tier â short label shown in notification cards + filenames. 72const QUALITY_LABEL = { quick: 'LQ', normal: 'MQ', high: 'HQ' }; 73function qualityLabel(q) { return QUALITY_LABEL[q] || ''; } 74let unsubscribeBalance = null; 75let pollIntervalId = null; 76const MAX_NOTIFICATIONS = 50; 77const POLL_INTERVAL_MS = 10000; // 10s â KV reads are edge-cached, near-zero cost 78 79// API base URL (same logic as client.js) 80const isDevelopment = window.location.hostname === 'localhost' 81 || window.location.hostname === '127.0.0.1' 82 || window.location.hostname.startsWith('192.168.') 83 || window.location.hostname.startsWith('10.') 84 || window.location.port === '3000'; 85const API_BASE_URL = isDevelopment ? 'http://localhost:8787' : 'https://api.arturum.ai'; 86 87// ============================================================================ 88// SINGLE INSTANCE 89// ============================================================================ 90// Pages import this module under different URLs (main.js uses ?v=â¦, others 91// don't), and every distinct URL is a separate module instance with its own 92// state. The first instance to init owns the header panel; the exported 93// functions of any other instance forward to it, so a notification added from 94// any page lands in the panel the user actually sees. 95const INSTANCE = {}; 96function panelOwner() { 97 const o = window.__arturumNotificationPanel; 98 return o && o.instance !== INSTANCE ? o : null; 99} 100 101// ============================================================================ 102// API HELPERS 103// ============================================================================ 104 105async function apiFetch(endpoint) { 106 const token = getAuthToken(); 107 if (!token) return null; 108 try { 109 const res = await fetch(`${API_BASE_URL}${endpoint}`, { 110 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, 111 }); 112 if (!res.ok) return null; 113 const json = await res.json(); 114 return json.data || json; 115 } catch (e) { 116 console.error('Notification API error:', e); 117 return null; 118 } 119} 120 121async function apiPost(endpoint, body = {}) { 122 const token = getAuthToken(); 123 if (!token) return null; 124 try { 125 const res = await fetch(`${API_BASE_URL}${endpoint}`, { 126 method: 'POST', 127 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, 128 body: JSON.stringify(body), 129 }); 130 if (!res.ok) return null; 131 const json = await res.json(); 132 return json.data || json; 133 } catch (e) { 134 console.error('Notification API error:', e); 135 return null; 136 } 137} 138 139// ============================================================================ 140// SOCIAL NOTIFICATION FETCHING 141// ============================================================================ 142 143async function fetchSocialNotifications(append = false) { 144 if (socialLoading) return; 145 socialLoading = true; 146 147 let url = '/api/notifications?limit=30'; 148 if (append && socialNextCursor) { 149 url += `&cursor=${encodeURIComponent(socialNextCursor)}`; 150 } 151 152 const data = await apiFetch(url); 153 socialLoading = false;
154 155 if (!data) return; 156 157 if (append) { 158 socialNotifications = [...socialNotifications, ...data.notifications]; 159 } else { 160 socialNotifications = data.notifications || []; 161 } 162 socialNextCursor = data.next_cursor || null; 163 unreadCount = data.unread_count || 0; 164 165 updateBellBadge(); 166 if (isOpen && activeTab === 'social') { 167 renderSocialList(); 168 } 169} 170 171async function fetchUnreadCount() { 172 const data = await apiFetch('/api/notifications/unread-count'); 173 if (data && typeof data.unread_count === 'number') { 174 const oldCount = unreadCount; 175 unreadCount = data.unread_count; 176 updateBellBadge(); 177 178 // New notifications arrived â grab attention 179 if (unreadCount > oldCount && oldCount >= 0) { 180 pulseNotificationBadge(); 181 // Auto-refresh the list if panel is open on Activity tab 182 if (isOpen && activeTab === 'social') { 183 fetchSocialNotifications(); 184 } 185 } 186 } 187} 188 189async function markAllAsRead() { 190 await apiPost('/api/notifications/read-all'); 191 unreadCount = 0; 192 socialNotifications = socialNotifications.map(n => ({ ...n, is_read: true })); 193 updateBellBadge(); 194 if (isOpen && activeTab === 'social') { 195 renderSocialList(); 196 } 197} 198 199// ============================================================================ 200// GENERATION NOTIFICATION MANAGEMENT 201// ============================================================================ 202 203/** 204 * Add a generation notification 205 */ 206export function addGenerationNotification(notification) { 207 const owner = panelOwner(); 208 if (owner) return owner.addGenerationNotification(notification); 209 if (notification.type === 'generation_completed' || notification.type === 'generation_failed') { 210 if (notification.jobId) { 211 const startedIndex = notifications.findIndex(n => 212 n.type === 'generation_started' && n.jobId === notification.jobId 213 ); 214 if (startedIndex !== -1) { 215 notifications.splice(startedIndex, 1); 216 } 217 const existingCompleted = notifications.find(n => 218 (n.type === 'generation_completed' || n.type === 'generation_failed') && n.jobId === notification.jobId 219 ); 220 if (existingCompleted) return; 221 } 222 } 223 224 if (notification.type === 'generation_started' && notification.jobId) { 225 const existingStarted = notifications.find(n => 226 n.type === 'generation_started' && n.jobId === notification.jobId 227 ); 228 if (existingStarted) return; 229 } 230 231 notifications.unshift({ 232 id: notification.jobId || Date.now(), 233 ...notification, 234 timestamp: notification.timestamp || Date.now(), 235 }); 236 237 if (notifications.length > MAX_NOTIFICATIONS) { 238 notifications = notifications.slice(0, MAX_NOTIFICATIONS); 239 } 240 241 if (isOpen) { 242 const isDL = isDownloadType(notification); 243 if (isDL && activeTab === 'download') renderDownloadList(); 244 else if (!isDL && activeTab === 'generation') renderGenerationList(); 245 } 246 247 updateBellBadge(); 248} 249 250export function clearNotifications() { 251 const owner = panelOwner(); 252 if (owner) return owner.clearNotifications(); 253 notifications = []; 254 if (isOpen) { 255 if (activeTab === 'generation') renderGenerationList(); 256 else if (activeTab === 'download') renderDownloadList(); 257 } 258 updateBellBadge(); 259} 260 261// ============================================================================ 262// PANEL RENDERING 263// ============================================================================ 264 265function createPanelHTML(balance) { 266 return ` 267 <div id="notification-panel" class="w-80 sm:w-96 bg-[#1a1a1a] rounded-xl border border-white/10 shadow-2xl overflow-hidden" style="animation: fadeIn 0.15s ease-out forwards;"> 268 <!-- Credit Balance Section --> 269 <div id="notification-credit-balance-section" 270 class="bg-[#222222] px-4 py-3 border-b border-[#2a2a2a] cursor-pointer hover:bg-[#2a2a2a] transition-colors group" 271 data-i18n-title="notif.click_get_more" title="Click to get more credits"> 272 <div class="flex items-center justify-between"> 273 <div class="flex items-center gap-2"> 274 <span class="material-symbols-outlined text-primary">toll</span>
275 <span class="text-sm font-medium text-white" data-i18n="notif.credit_balance">Credit Balance</span> 276 </div> 277 <div class="text-right flex items-center gap-2"> 278 <span class="text-lg font-bold text-white" id="notification-credits-total">${formatCredits(balance.total)}</span> 279 <span class="text-xs text-gray-400" data-i18n="notif.credits">credits</span> 280 <span class="material-symbols-outlined text-gray-500 group-hover:text-primary text-sm transition-colors">add_circle</span> 281 </div> 282 </div> 283 <div class="mt-2 flex items-center justify-between"> 284 <div id="notification-credits-breakdown" class="flex gap-3 text-xs text-gray-400"> 285 ${balance.subscription > 0 ? `<span><span data-i18n="notif.monthly">Monthly</span>: ${formatCredits(balance.subscription)}</span>` : ''} 286 ${balance.purchased > 0 ? `<span><span data-i18n="notif.purchased">Purchased</span>: ${formatCredits(balance.purchased)}</span>` : ''} 287 ${balance.bonus > 0 ? `<span><span data-i18n="notif.bonus">Bonus</span>: ${formatCredits(balance.bonus)}</span>` : ''} 288 </div> 289 <span class="text-xs text-gray-500 group-hover:text-primary transition-colors" data-i18n="notif.top_up">Top up â</span> 290 </div> 291 </div> 292 293 <!-- Tab Switcher --> 294 <div class="flex border-b border-[#2a2a2a]"> 295 <button id="notif-tab-social" class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors relative ${activeTab === 'social' ? 'text-white' : 'text-gray-500 hover:text-gray-300'}"> 296 <span data-i18n="notif.tab_activity">Activity</span> 297 ${unreadCount >
297 0 ? `<span class="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold bg-red-500 text-white rounded-full">${unreadCount > 99 ? '99+' : unreadCount}</span>` : ''} 298 ${activeTab === 'social' ? '<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full"></div>' : ''} 299 </button> 300 <button id="notif-tab-generation" class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors relative ${activeTab === 'generation' ? 'text-white' : 'text-gray-500 hover:text-gray-300'}"> 301 <span data-i18n="notif.tab_generations">Generations</span> 302 ${activeTab === 'generation' ? '<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full"></div>' : ''} 303 </button> 304 <button id="notif-tab-download" class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors relative ${activeTab === 'download' ? 'text-white' : 'text-gray-500 hover:text-gray-300'}"> 305 <span data-i18n="notif.tab_downloads">Downloads</span> 306 ${activeTab === 'download' ? '<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full"></div>' : ''} 307 </button> 308 </div> 309 310 <!-- Content Area --> 311 <div class="max-h-80 overflow-y-auto" id="notification-list"> 312 ${activeTab === 'social' ? renderSocialListHTML() : activeTab === 'download' ? renderDownloadListHTML() : renderGenerationListHTML()} 313 </div> 314 </div> 315 `; 316} 317 318function createGuestPanelHTML() { 319 return ` 320 <div id="notification-panel" class="w-80 sm:w-96 bg-[#1a1a1a] rounded-xl border border-white/10 shadow-2xl overflow-hidden" style="animation: fadeIn 0.15s ease-out forwards;"> 321 <div class="px-5 pt-5 pb-4 text-center"> 322 <div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-primary/15 mb-3"> 323 <span class="material-symbols-outlined text-primary text-3xl">auto_awesome</span> 324 </div> 325 <h3 class="text-lg font-bold text-white mb-1" data-i18n="notif.guest_title">150 Free Credits</h3> 326 <p class="text-sm text-gray-400 leading-relaxed" data-i18n="notif.guest_subtitle">Sign up and start creating AI images, videos & music â on us.</p> 327 </div> 328 <div class="px-5 pb-4"> 329 <div class="flex justify-center gap-4 text-xs text-gray-500"> 330 <span class="flex items-center gap-1"><span class="material-symbols-outlined text-sm text-primary/70">image</span> <span data-i18n="notif.images">Images</span></span> 331 <span class="flex items-center gap-1"><span class="material-symbols-outlined text-sm text-primary/70">movie</span> <span data-i18n="notif.videos">Videos</span></span> 332 <span class="flex items-center gap-1"><span class="material-symbols-outlined text-sm text-primary/70">music_note</span> <span data-i18n="notif.music">Music</span></span> 333 </div> 334 </div> 335 <div class="px-5 pb-4 flex flex-col gap-2"> 336 <button id="guest-signup-btn" class="w-full py-2.5 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer" data-i18n="common.login"> 337 Login 338 </button> 339 <button id="guest-upgrade-btn" class="w-full py-2.5 rounded-lg bg-white/10 text-white text-sm font-medium hover:bg-white/15 transition-colors cursor-pointer flex items-center justify-center gap-1.5"> 340 <span class="material-symbols-outlined text-sm">diamond</span> 341 <span data-i18n="notif.view_plans">View Plans & Pricing</span> 342 </button> 343 </div> 344 </div> 345 `; 346} 347 348// ============================================================================ 349// SOCIAL NOTIFICATION RENDERING 350// ============================================================================ 351 352function getNotifMeta(type, isMedia, isProject) { 353 const target = isProject ? 'your project' : isMedia ? 'your creation' : 'your post'; 354 switch (type) { 355 case 'like': return { icon: 'favorite', color: 'text-red-400', label: `liked ${target}` }; 356 case 'comment': return { icon: 'chat_bubble', color: 'text-blue-400', label: `commented on ${target}` }; 357 case 'follow': return { icon: 'person_add', color: 'text-green-400', label: 'started following you' }; 358 case 'remix': return { icon: 'replay', color: 'text-purple-400', label: `remixed ${target}` }; 359 case 'mention': return { icon: 'alternate_email', color: 'text-yellow-400', label: 'mentioned you' }; 360 case 'milestone': return { icon: 'emoji_events', color: 'text-amber-400', label: 'milestone reached' }; 361 case 'poll_end': return { icon: 'poll', color: 'text-cyan-400', label: `poll ended on ${target}` }; 362 default: return { icon: 'notifications', color: 'text-gray-400', label: 'notification' }; 363 } 364} 365 366function getNotifLink(n) { 367 if (n.type === 'follow') { 368 return `/profile/${n.actor?.username || ''}`; 369 } 370 // Community post notification 371 if (n.post_id) { 372 return `/community/post/${n.post_id}`; 373 } 374 // Project comment/like â link directly to the project 375 if (n.media_id && n.media_type === 'project') { 376 return `/project/${n.media_id}`; 377 } 378 // Profile media notification â link to the media owner's profile 379 if (n.media_id && n.media_owner_username) { 380 return `/profile/${n.media_owner_username}`; 381 } 382 return null; 383} 384
385function renderSocialListHTML() { 386 if (socialLoading && socialNotifications.length === 0) { 387 return ` 388 <div class="text-center py-8 text-gray-500"> 389 <span class="material-symbols-outlined text-2xl mb-2 block animate-spin">progress_activity</span> 390 <p class="text-sm" data-i18n="common.loading">Loading...</p> 391 </div> 392 `; 393 } 394 395 if (socialNotifications.length === 0) { 396 return ` 397 <div class="text-center py-8 text-gray-500"> 398 <span class="material-symbols-outlined text-3xl mb-2 block">notifications_off</span> 399 <p class="text-sm" data-i18n="notif.empty_social">No notifications yet</p> 400 <p class="text-xs mt-1" data-i18n="notif.empty_social_hint">Likes, comments and follows will appear here</p> 401 </div> 402 `; 403 } 404 405 let html = ''; 406 407 // "Mark all read" button if there are unread 408 if (unreadCount > 0) { 409 html += ` 410 <div class="px-4 py-1.5 flex justify-end border-b border-white/5"> 411 <button id="notif-mark-all-read" class="text-xs text-gray-400 hover:text-white transition-colors cursor-pointer" data-i18n="notif.mark_all_read">Mark all read</button> 412 </div> 413 `; 414 } 415 416 html += socialNotifications.map(n => { 417 const isMedia = !!n.media_id && !n.post_id; 418 const isProject = isMedia && n.media_type === 'project'; 419 const meta = getNotifMeta(n.type, isMedia, isProject); 420 const link = getNotifLink(n); 421 const timeAgo = getTimeAgo(new Date(n.created_at).getTime()); 422 const actorName = n.actor?.display_name || n.actor?.username || 'Someone'; 423 const unreadDot = !n.is_read ? '<span class="w-2 h-2 rounded-full bg-primary flex-shrink-0"></span>' : ''; 424 425 // Avatar or icon 426 let avatarHtml; 427 if (n.actor?.avatar_url) { 428 const thumbUrl = tinyThumb(n.actor.avatar_url, 40); 429 avatarHtml = `<img src="${thumbUrl}" alt="" class="w-10 h-10 rounded-full object-cover" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> 430 <div class="w-10 h-10 rounded-full bg-white/10 items-center justify-center flex-shrink-0 hidden"><span class="material-symbols-outlined ${meta.color}">${meta.icon}</span></div>`; 431 } else { 432 avatarHtml = `<div class="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0"><span class="material-symbols-outlined ${meta.color}">${meta.icon}</span></div>`; 433 } 434 435 // Thumbnail (right side) â post media or profile media 436 let thumbHtml = ''; 437 const thumbSrcRaw = n.post_thumbnail_url || n.media_thumbnail_url || (isMedia ? n.media_id : null); 438 if (thumbSrcRaw && n.type !== 'follow') { 439 const thumbSrc = tinyThumb(thumbSrcRaw, 48); 440 thumbHtml = `<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-white/5 ml-2"> 441 <img src="${thumbSrc}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.parentElement.style.display='none'" /> 442 </div>`; 443 } 444 445 // Description text 446 let descText = ''; 447 if (n.type === 'comment' && n.comment_body) { 448 const truncated = n.comment_body.length > 60 ? n.comment_body.substring(0, 60) + '...' : n.comment_body; 449 descText = `<p class="text-xs text-gray-500 mt-0.5 truncate">"${truncated}"</p>`; 450 } 451 452 const clickAttr = link ? `data-notif-link="${link}" data-notif-id="${n.id}" style="cursor:pointer"` : ''; 453 const unreadBg = !n.is_read ? 'bg-white/[0.03]' : ''; 454 455 return ` 456 <div class="notif-item px-4 py-3 hover:bg-white/5 transition-colors border-b border-white/5 last:border-b-0 ${unreadBg}" ${clickAttr}> 457 <div class="flex items-start gap-3"> 458 <div class="flex-shrink-0 relative">${avatarHtml}</div> 459 <div class="flex-1 min-w-0"> 460 <div class="flex items-center gap-1.5"> 461 ${unreadDot} 462 <span class="text-sm text-white"><span class="font-medium">${escapeHtml(actorName)}</span> <span class="text-gray-400">${meta.label}</span></span> 463 </div> 464 ${descText} 465 <span class="text-[11px] text-gray-600 mt-0.5 block">${timeAgo}</span> 466 </div> 467 ${thumbHtml} 468 </div> 469 </div> 470 `; 471 }).join(''); 472 473 // Load more button 474 if (socialNextCursor) { 475 html += ` 476 <div class="px-4 py-3 text-center border-t border-white/5"> 477 <button id="notif-load-more" class="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer" data-i18n="notif.load_more">Load more</button> 478 </div> 479 `; 480 } 481 482 return html; 483} 484
485function renderSocialList() { 486 const listEl = document.getElementById('notification-list'); 487 if (!listEl) return; 488 listEl.innerHTML = renderSocialListHTML(); 489 attachSocialListeners(); 490} 491 492function attachSocialListeners() { 493 // Click-to-navigate on notification items 494 document.querySelectorAll('[data-notif-link]').forEach(el => { 495 el.addEventListener('click', (e) => { 496 e.preventDefault(); 497 const link = el.getAttribute('data-notif-link'); 498 const notifId = el.getAttribute('data-notif-id'); 499 500 // Mark as read 501 if (notifId) { 502 apiPost('/api/notifications/read', { ids: [notifId] }); 503 const n = socialNotifications.find(sn => sn.id === notifId); 504 if (n && !n.is_read) { 505 n.is_read = true; 506 unreadCount = Math.max(0, unreadCount - 1); 507 updateBellBadge(); 508 } 509 } 510 511 closeNotificationPanel(); 512 513 // Navigate using SPA router 514 if (link && window.ARTURUM && typeof window.ARTURUM.navigate === 'function') { 515 window.ARTURUM.navigate(link); 516 } else if (link) { 517 window.location.href = link; 518 } 519 }); 520 }); 521 522 // Mark all read button 523 const markAllBtn = document.getElementById('notif-mark-all-read'); 524 if (markAllBtn) { 525 markAllBtn.addEventListener('click', (e) => { 526 e.stopPropagation(); 527 markAllAsRead(); 528 }); 529 } 530 531 // Load more button
532 const loadMoreBtn = document.getElementById('notif-load-more'); 533 if (loadMoreBtn) { 534 loadMoreBtn.addEventListener('click', (e) => { 535 e.stopPropagation(); 536 loadMoreBtn.textContent = 'Loading...'; 537 fetchSocialNotifications(true); 538 }); 539 } 540} 541 542// ============================================================================ 543// GENERATION NOTIFICATION RENDERING 544// ============================================================================ 545 546function isDownloadType(n) { 547 return n && typeof n.type === 'string' && n.type.startsWith('download_'); 548} 549 550function renderGenerationListHTML() { 551 // The Generations tab only shows AI-generation activity â download 552 // notifications go into their own "Downloads" tab. 553 const items = notifications.filter(n => !isDownloadType(n)); 554 if (items.length === 0) { 555 return ` 556 <div class="text-center py-8 text-gray-500"> 557 <span class="material-symbols-outlined text-3xl mb-2 block">notifications_off</span> 558 <p class="text-sm" data-i18n="notif.empty_gen">No generations yet</p> 559 <p class="text-xs mt-1" data-i18n="notif.empty_gen_hint">Your AI generation history will appear here</p> 560 </div> 561 `; 562 } 563 564 let html = ` 565 <div class="px-4 py-1.5 flex justify-end border-b border-white/5"> 566 <button id="notif-clear-gen" class="text-xs text-gray-400 hover:text-white transition-colors cursor-pointer" data-i18n="notif.clear_all">Clear all</button> 567 </div> 568 `; 569 570 html += items.map(n => createGenerationItemHTML(n)).join(''); 571 return html; 572} 573 574function renderDownloadListHTML() { 575 const items = notifications.filter(isDownloadType); 576 if (items.length === 0) { 577 return ` 578 <div class="text-center py-8 text-gray-500"> 579 <span class="material-symbols-outlined text-3xl mb-2 block">download</span> 580 <p class="text-sm" data-i18n="notif.empty_dl">No downloads yet</p> 581 <p class="text-xs mt-1" data-i18n="notif.empty_dl_hint">Exported videos will appear here with live progress</p> 582 </div> 583 `; 584 } 585 let html = ` 586 <div class="px-4 py-1.5 flex justify-end border-b border-white/5"> 587 <button id="notif-clear-dl" class="text-xs text-gray-400 hover:text-white transition-colors cursor-pointer" data-i18n="notif.clear_all">Clear all</button> 588 </div> 589 `; 590 html += items.map(n => createGenerationItemHTML(n)).join(''); 591 return html; 592} 593 594function renderDownloadList() { 595 const listEl = document.getElementById('notification-list'); 596 if (!listEl) return; 597 listEl.innerHTML = renderDownloadListHTML(); 598 const clearBtn = document.getElementById('notif-clear-dl'); 599 if (clearBtn) { 600 clearBtn.addEventListener('click', (e) => { 601 e.stopPropagation(); 602 // Clear only download-type notifications; leave AI generations alone. 603 notifications = notifications.filter(n => !isDownloadType(n)); 604 renderDownloadList(); 605 saveNotifications(); 606 updateBellBadge(); 607 }); 608 } 609} 610 611function renderGenerationList() { 612 const listEl = document.getElementById('notification-list'); 613 if (!listEl) return; 614 listEl.innerHTML = renderGenerationListHTML(); 615 attachGenerationListeners(); 616} 617 618function attachGenerationListeners() { 619 const clearBtn = document.getElementById('notif-clear-gen'); 620 if (clearBtn) { 621 clearBtn.addEventListener('click', (e) => { 622 e.stopPropagation(); 623 clearNotifications(); 624 saveNotifications(); 625 }); 626 } 627} 628 629function createGenerationItemHTML(notification) { 630 const timeAgo = getTimeAgo(notification.timestamp); 631 632 let icon, iconColor, title, description; 633 let extraHtml = ''; 634 635 switch (notification.type) { 636 case 'generation_started': 637 icon = 'hourglass_top'; 638 iconColor = 'text-yellow-400'; 639 title = 'Generation Started'; 640 description = `${notification.model} - ${notification.credits} credits`; 641 break; 642 case 'generation_completed': 643 icon = 'check_circle'; 644 iconColor = 'text-green-400'; 645 title = 'Generation Complete'; 646 description = notification.prompt?.substring(0, 50) + (notification.prompt?.length > 50 ? '...' : ''); 647 break; 648 case 'generation_failed': 649 icon = 'error'; 650 iconColor = 'text-red-400'; 651 title = 'Generation Failed'; 652 description = `Refunded: ${notification.credits} credits`; 653 break; 654 case 'credits_added': 655 icon = 'add_circle'; 656 iconColor = 'text-primary'; 657 title = 'Credits Added'; 658 description = `+${notification.credits} credits`; 659 break; 660 case 'download_started': { 661 icon = 'download'; 662 iconColor = 'text-primary'; 663 const ql = qualityLabel(notification.quality);
664 title = ql ? `Export Running <span class="ml-1 px-1.5 py-0.5 text-[9px] font-bold tracking-wider bg-primary/25 text-primary rounded">${ql}</span>` : 'Export Running'; 665 description = notification.message || `${notification.projectTitle || 'Video'}`; 666 // Inline progress bar inside the card so the user can track 667 // the export without reopening the modal. 668 extraHtml = ` 669 <div class="mt-2"> 670 <div class="flex items-center justify-between text-[10px] text-gray-400 mb-1"> 671 <span>${(notification.phase || 'processing').toUpperCase()}</span> 672 <span data-download-pct="${notification.id}">${Math.round(notification.progress || 0)}%</span> 673 </div> 674 <div class="w-full h-1.5 bg-white/5 rounded-full overflow-hidden"> 675 <div data-download-bar="${notification.id}" class="h-full bg-primary transition-all duration-300" style="width: ${Math.round(notification.progress || 0)}%"></div> 676 </div> 677 </div> 678 `; 679 break; 680 } 681 case 'download_completed': { 682 icon = 'download_done'; 683 iconColor = 'text-green-400'; 684 const ql = qualityLabel(notification.quality); 685 title = ql ? `Export Ready <span class="ml-1 px-1.5 py-0.5 text-[9px] font-bold tracking-wider bg-green-500/25 text-green-400 rounded">${ql}</span>` : 'Export Ready'; 686 description = notification.filename || 'Your video is ready'; 687 extraHtml = notification.downloadUrl ? ` 688 <a href="${notification.downloadUrl}" download="${notification.filename || 'video.mp4'}" class="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary text-xs font-medium rounded-md transition-colors" onclick="event.stopPropagation();"> 689 <span class="material-symbols-outlined" style="font-size:14px">download</span> 690 Download again 691 </a> 692 ` : ''; 693 break; 694 } 695 case 'download_failed': 696 icon = 'error'; 697 iconColor = 'text-red-400'; 698 title = 'Export Failed'; 699 description = notification.message || 'Export did not finish'; 700 break; 701 default: 702 icon = 'info'; 703 iconColor = 'text-gray-400'; 704 title = 'Notification'; 705 description = notification.message || ''; 706 } 707 708 const isMusicModel = notification.model && notification.model.includes('music'); 709 710 let thumbnailSrc = notification.thumbnailUrl; 711 if (thumbnailSrc && isCloudinaryUrl(thumbnailSrc)) { 712 if (thumbnailSrc.includes('/video/upload/')) { 713 thumbnailSrc = getVideoThumbnail(thumbnailSrc, { width: 96, time: 1 }); 714 } else { 715 thumbnailSrc = thumbnailSrc.replace('/upload/', '/upload/w_96,h_96,c_fill,q_auto/'); 716 } 717 } 718 719 const musicThumbnail = isMusicModel ? ` 720 <div class="w-12 h-12 rounded-lg bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center flex-shrink-0"> 721 <span class="material-symbols-outlined text-primary text-xl">music_note</span> 722 </div> 723 ` : null; 724 725 return ` 726 <div class="px-4 py-3 hover:bg-white/5 transition-colors border-b border-white/5 last:border-b-0"> 727 <div class="flex gap-3"> 728 ${musicThumbnail ? musicThumbnail : thumbnailSrc ? ` 729 <div class="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 bg-white/5"> 730 <img src="${thumbnailSrc}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none';this.parentElement.innerHTML='<span class=\\'material-symbols-outlined ${iconColor} text-xl flex items-center justify-center w-full h-full\\'>${icon}</span>'" /> 731 </div> 732 ` : ` 733 <div class="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center flex-shrink-0"> 734 <span class="material-symbols-outlined ${iconColor}">${icon}</span> 735 </div> 736 `} 737 <div class="flex-1 min-w-0"> 738 <div class="flex items-center justify-between gap-2"> 739 <span class="text-sm font-medium text-white">${title}</span> 740 <span class="text-xs text-gray-500 flex-shrink-0">${timeAgo}</span> 741 </div> 742 <p class="text-xs text-gray-400 mt-0.5 truncate">${description}</p> 743 ${extraHtml} 744 </div> 745 </div> 746 </div> 747 `; 748} 749 750/** 751 * Update an existing notification (by id) with patch fields. Useful for 752 * progress updates on in-flight downloads â we avoid re-rendering the 753 * whole panel by poking the inline progress bar + percent label. 754 */ 755export function updateNotificationProgress(id, patch = {}) { 756 const owner = panelOwner(); 757 if (owner) return owner.updateNotificationProgress(id, patch); 758 const idx = notifications.findIndex(n => n.id === id); 759 if (idx === -1) return; 760 Object.assign(notifications[idx], patch); 761 // Fast DOM patch for progress-only updates so we don't re-render 762 // the whole list (jarring while the panel is open). 763 if (typeof patch.progress === 'number') { 764 const bar = document.querySelector(`[data-download-bar="${id}"]`); 765 const pct = document.querySelector(`[data-download-pct="${id}"]`); 766 if (bar) bar.style.width = `${Math.round(patch.progress)}%`; 767 if (pct) pct.textContent = `${Math.round(patch.progress)}%`; 768 } 769 // If the type changed (started â completed / failed) we need a full 770 // re-render to swap the icon + extra UI. 771 if (patch.type && isOpen) { 772 if (activeTab === 'download') renderDownloadList(); 773 else if (activeTab === 'generation') renderGenerationList(); 774 } 775 saveNotifications(); 776 updateBellBadge(); 777} 778 779// ============================================================================ 780// CREDIT DISPLAY 781// ============================================================================ 782 783function updateCreditDisplay(balance) { 784 const totalEl = document.getElementById('notification-credits-total'); 785 if (totalEl) { 786 totalEl.textContent = formatCredits(balance.total); 787 } 788 const breakdownEl = document.getElementById('notification-credits-breakdown'); 789 if (breakdownEl && balance.total > 0) { 790 let breakdown = []; 791 if (balance.subscription > 0) breakdown.push(`Monthly: ${formatCredits(balance.subscription)}`); 792 if (balance.purchased > 0) breakdown.push(`Purchased: ${formatCredits(balance.purchased)}`); 793 if (balance.bonus > 0) breakdown.push(`Bonus: ${formatCredits(balance.bonus)}`); 794 breakdownEl.innerHTML = breakdown.map(s => `<span>${s}</span>`).join(''); 795 } 796} 797 798// ============================================================================ 799// PANEL LIFECYCLE 800// ============================================================================ 801 802export function openNotificationPanel() { 803 const owner = panelOwner(); 804 if (owner) return owner.openNotificationPanel(); 805 if (isOpen) return; 806 807 const token = getAuthToken(); 808 const isGuest = !token; 809 810 const creditsBtn = document.getElementById('header-credits-btn'); 811 if (!creditsBtn) return; 812 813 closeNotificationPanel(); 814 815 // Animation keyframes (injected once) 816 injectNotifStyles(); 817 818 const rect = creditsBtn.getBoundingClientRect(); 819 const panelWrapper = document.createElement('div'); 820 panelWrapper.id = 'notification-panel-wrapper'; 821 panelWrapper.style.position = 'fixed'; 822 panelWrapper.style.top = `${rect.bottom + 8}px`; 823 panelWrapper.style.right = `${window.innerWidth - rect.right}px`; 824 panelWrapper.style.zIndex = '9999'; 825 826 if (isGuest) { 827 panelWrapper.innerHTML = createGuestPanelHTML(); 828 } else { 829 const balance = getCachedBalanceSync(); 830 refreshCreditBalance().then(updatedBalance => { 831 updateCreditDisplay(updatedBalance); 832 }); 833 panelWrapper.innerHTML = createPanelHTML(balance); 834
835 // Fetch social notifications when opening 836 fetchSocialNotifications(); 837 } 838 839 document.body.appendChild(panelWrapper); 840 panelElement = document.getElementById('notification-panel'); 841 isOpen = true; 842 843 if (!isGuest) { 844 unsubscribeBalance = onCreditBalanceChange(updateCreditDisplay); 845 } 846 847 // Credit balance click â pricing modal 848 const creditBalanceSection = document.getElementById('notification-credit-balance-section'); 849 if (creditBalanceSection) { 850 creditBalanceSection.addEventListener('click', (e) => { 851 e.preventDefault(); 852 e.stopPropagation(); 853 closeNotificationPanel(); 854 import('./CreditsModal.js').then(m => m.openCreditsModal()); 855 }); 856 } 857 858 // Tab switching 859 const tabSocial = document.getElementById('notif-tab-social'); 860 const tabGen = document.getElementById('notif-tab-generation'); 861 const tabDownload = document.getElementById('notif-tab-download'); 862 if (tabSocial) { 863 tabSocial.addEventListener('click', (e) => { 864 e.stopPropagation(); 865 if (activeTab === 'social') return; 866 activeTab = 'social'; 867 refreshTabs(); 868 renderSocialList(); 869 }); 870 } 871 if (tabGen) { 872 tabGen.addEventListener('click', (e) => { 873 e.stopPropagation(); 874 if (activeTab === 'generation') return; 875 activeTab = 'generation'; 876 refreshTabs(); 877 renderGenerationList(); 878 attachGenerationListeners(); 879 }); 880 } 881 if (tabDownload) { 882 tabDownload.addEventListener('click', (e) => { 883 e.stopPropagation(); 884 if (activeTab === 'download') return; 885 activeTab = 'download'; 886 refreshTabs(); 887 renderDownloadList(); 888 }); 889 } 890 891 // Attach listeners for social items 892 if (!isGuest) { 893 attachSocialListeners(); 894 } 895 896 // Guest buttons 897 const guestSignupBtn = document.getElementById('guest-signup-btn'); 898 if (guestSignupBtn) { 899 guestSignupBtn.addEventListener('click', (e) => { 900 e.preventDefault(); 901 e.stopPropagation(); 902 closeNotificationPanel(); 903 if (window.ensureLoginModal) window.ensureLoginModal().then(m => m.open()); 904 }); 905 } 906 907 const guestUpgradeBtn = document.getElementById('guest-upgrade-btn'); 908 if (guestUpgradeBtn) { 909 guestUpgradeBtn.addEventListener('click', (e) => { 910 e.preventDefault(); 911 e.stopPropagation(); 912 closeNotificationPanel(); 913 import('./CreditsModal.js').then(m => m.openCreditsModal()); 914 }); 915 } 916 917 // Close listeners 918 setTimeout(() => { 919 document.addEventListener('click', handleClickOutside); 920 document.addEventListener('keydown', handleEscapeKey); 921 }, 0); 922 923 window.clearNotifications = clearNotifications; 924} 925 926function refreshTabs() { 927 const tabSocial = document.getElementById('notif-tab-social'); 928 const tabGen = document.getElementById('notif-tab-generation'); 929 if (tabSocial) { 930 tabSocial.className = `flex-1 px-4 py-2.5 text-sm font-medium transition-colors relative ${activeTab === 'social' ? 'text-white' : 'text-gray-500 hover:text-gray-300'}`; 931 // Rebuild inner HTML to update indicator + badge 932 tabSocial.innerHTML = `Activity 933 ${unreadCount >
933 0 ? `<span class="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold bg-red-500 text-white rounded-full">${unreadCount > 99 ? '99+' : unreadCount}</span>` : ''} 934 ${activeTab === 'social' ? '<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full"></div>' : ''}`; 935 } 936 if (tabGen) { 937 tabGen.className = `flex-1 px-4 py-2.5 text-sm font-medium transition-colors relative ${activeTab === 'generation' ? 'text-white' : 'text-gray-500 hover:text-gray-300'}`; 938 tabGen.innerHTML = `Generations 939 ${activeTab === 'generation' ? '<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-primary rounded-full"></div>' : ''}`; 940 } 941} 942 943export function closeNotificationPanel() { 944 const owner = panelOwner(); 945 if (owner) return owner.closeNotificationPanel(); 946 const wrapper = document.getElementById('notification-panel-wrapper'); 947 if (wrapper) wrapper.remove(); 948 949 panelElement = null; 950 isOpen = false; 951 952 if (unsubscribeBalance) { 953 unsubscribeBalance(); 954 unsubscribeBalance = null; 955 } 956 957 document.removeEventListener('click', handleClickOutside); 958 document.removeEventListener('keydown', handleEscapeKey); 959 delete window.clearNotifications; 960} 961 962export function toggleNotificationPanel() { 963 const owner = panelOwner(); 964 if (owner) return owner.toggleNotificationPanel(); 965 if (isOpen) { 966 closeNotificationPanel(); 967 } else { 968 openNotificationPanel(); 969 } 970} 971 972// ============================================================================ 973// EVENT HANDLERS 974// ============================================================================ 975 976function handleClickOutside(e) { 977 if (panelElement && !panelElement.contains(e.target) && !e.target.closest('#header-credits-btn')) { 978 closeNotificationPanel(); 979 } 980} 981 982function handleEscapeKey(e) { 983 if (e.key === 'Escape') { 984 closeNotificationPanel(); 985 } 986} 987 988function updateBellBadge() { 989 const creditsBtn = document.getElementById('header-credits-btn'); 990 if (!creditsBtn) return; 991 992 // Combine: generation recent count + social unread count 993 const oneHourAgo = Date.now() - 60 * 60 * 1000; 994 const genRecentCount = notifications.filter(n => n.timestamp > oneHourAgo).length; 995 const totalCount = genRecentCount + unreadCount; 996 997 let badge = creditsBtn.querySelector('.notification-badge'); 998 999 if (totalCount > 0) { 1000 if (!badge) { 1001 badge = document.createElement('span'); 1002 badge.className = 'notification-badge'; 1003 badge.style.cssText = 'position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:2px 6px;background:#ef4444;color:#fff;font-size:11px;font-weight:700;border-radius:9999px;display:flex;align-items:center;justify-content:center;line-height:1;animation:notifBadgePop 0.4s cubic-bezier(0.34,1.56,0.64,1)'; 1004 creditsBtn.style.position = 'relative'; 1005 creditsBtn.appendChild(badge); 1006 } 1007 badge.textContent = totalCount > 99 ? '99+' : totalCount; 1008 } else if (badge) { 1009 badge.remove(); 1010 } 1011} 1012 1013/** 1014 * Pulse the notification badge + credits button to grab attention. 1015 * Called when new unread notifications are detected via polling. 1016 */ 1017function pulseNotificationBadge() { 1018 const creditsBtn = document.getElementById('header-credits-btn'); 1019 if (!creditsBtn) return; 1020 1021 const badge = creditsBtn.querySelector('.notification-badge'); 1022 1023 // Bounce the badge 1024 if (badge) {
1025 badge.style.animation = 'none'; 1026 // Force reflow 1027 void badge.offsetWidth; 1028 badge.style.animation = 'notifBadgeBounce 0.6s cubic-bezier(0.34,1.56,0.64,1)'; 1029 } 1030 1031 // Subtle glow ring on the credits button 1032 creditsBtn.classList.add('notif-glow'); 1033 setTimeout(() => creditsBtn.classList.remove('notif-glow'), 2000); 1034} 1035 1036function updateHeaderCredits(balance) { 1037 const creditsCount = document.getElementById('header-credits-count'); 1038 if (creditsCount && balance) { 1039 creditsCount.textContent = formatCredits(balance.total); 1040 } 1041 const mobileCreditsCount = document.getElementById('mobile-credits-count'); 1042 if (mobileCreditsCount && balance) { 1043 mobileCreditsCount.textContent = formatCredits(balance.total); 1044 } 1045} 1046 1047// ============================================================================ 1048// HELPERS 1049// ============================================================================ 1050 1051function getTimeAgo(timestamp) { 1052 const seconds = Math.floor((Date.now() - timestamp) / 1000); 1053 if (seconds < 60) return 'Just now'; 1054 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 1055 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 1056 if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 1057 return `${Math.floor(seconds / 604800)}w ago`; 1058} 1059 1060function escapeHtml(str) { 1061 if (!str) return ''; 1062 return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 1063} 1064 1065function injectNotifStyles() { 1066 if (document.getElementById('notification-panel-styles')) return; 1067 const style = document.createElement('style'); 1068 style.id = 'notification-panel-styles'; 1069 style.textContent = ` 1070 @keyframes fadeIn { 1071 from { opacity: 0; transform: translateY(-8px); } 1072 to { opacity: 1; transform: translateY(0); } 1073 } 1074 @keyframes notifBadgePop { 1075 0% { transform: scale(0); opacity: 0; } 1076 60% { transform: scale(1.3); opacity: 1; } 1077 100% { transform: scale(1); } 1078 } 1079 @keyframes notifBadgeBounce { 1080 0% { transform: scale(1); } 1081 20% { transform: scale(1.4); } 1082 40% { transform: scale(0.9); } 1083 60% { transform: scale(1.2); } 1084 80% { transform: scale(0.95); } 1085 100% { transform: scale(1); } 1086 } 1087 @keyframes notifGlowPulse { 1088 0% { box-shadow: 0 0 0 0 rgba(139,92,246,0.5); } 1089 50% { box-shadow: 0 0 12px 4px rgba(139,92,246,0.35); } 1090 100% { box-shadow: 0 0 0 0 rgba(139,92,246,0); } 1091 } 1092 .notif-glow { 1093 animation: notifGlowPulse 1s ease-in-out 2; 1094 } 1095 `; 1096 document.head.appendChild(style); 1097} 1098 1099// ============================================================================ 1100// INITIALIZATION 1101// ============================================================================ 1102 1103export function initNotificationPanel() { 1104 // Another instance already owns the header panel â don't attach a second 1105 // set of click handlers / pollers. 1106 if (panelOwner()) return; 1107 if (window.__arturumNotificationPanel) return; // this instance already initialised 1108 window.__arturumNotificationPanel = { 1109 instance: INSTANCE, 1110 addGenerationNotification: addAndSaveNotification, 1111 clearNotifications, 1112 updateNotificationProgress, 1113 openNotificationPanel, 1114 closeNotificationPanel, 1115 toggleNotificationPanel, 1116 }; 1117 1118 const creditsBtn = document.getElementById('header-credits-btn'); 1119 1120 if (creditsBtn) { 1121 creditsBtn.setAttribute('aria-label', 'credits-and-notifications'); 1122 creditsBtn.addEventListener('click', (e) => { 1123 e.preventDefault(); 1124 e.stopPropagation(); 1125 toggleNotificationPanel(); 1126 }); 1127 } 1128 1129 // Mobile menu credits button 1130 const mobileCreditsBtn = document.getElementById('mobile-credits-btn'); 1131 if (mobileCreditsBtn) { 1132 mobileCreditsBtn.addEventListener('click', (e) => { 1133 e.preventDefault(); 1134 e.stopPropagation(); 1135 const mobileMenu = document.getElementById('mobile-menu'); 1136 if (mobileMenu) mobileMenu.classList.add('hidden'); 1137 toggleNotificationPanel(); 1138 }); 1139 } 1140 1141 // Subscribe to credit balance changes 1142 onCreditBalanceChange((balance) => { 1143 updateHeaderCredits(balance); 1144 }); 1145 1146 const cachedBalance = getCachedBalanceSync(); 1147 if (cachedBalance && cachedBalance.total > 0) { 1148 updateHeaderCredits(cachedBalance); 1149 } 1150 1151 refreshCreditBalance().catch(err => { 1152 console.warn('Failed to refresh credit balance:', err); 1153 }); 1154 1155 // Load generation notifications from localStorage 1156 try { 1157 const saved = localStorage.getItem('arturum_notifications'); 1158 if (saved) { 1159 let loadedNotifications = JSON.parse(saved); 1160 const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); 1161 loadedNotifications = loadedNotifications.filter(n => { 1162 if (n.type === 'generation_started' && n.timestamp < fiveMinutesAgo) return false;
1163 return true; 1164 }); 1165 1166 const seenByJobId = new Set(); 1167 notifications = loadedNotifications.filter(n => { 1168 if (n.jobId) { 1169 const key = `${n.type}-${n.jobId}`; 1170 if (seenByJobId.has(key)) return false; 1171 seenByJobId.add(key); 1172 } 1173 return true; 1174 }); 1175 1176 saveNotifications(); 1177 } 1178 } catch (e) { 1179 console.error('Failed to load notifications:', e); 1180 } 1181 1182 // Inject animation styles early so badge animations work immediately 1183 injectNotifStyles(); 1184 1185 // ââ Listen for export events from VideoExporter ââââââââââââââââ 1186 // Kept event-based (instead of direct imports) so callers don't 1187 // need a module dependency. All three events share the same 1188 // notification id so the card updates in place instead of stacking. 1189 window.addEventListener('arturum:export-started', (e) => { 1190 const d = e.detail || {}; 1191 addGenerationNotification({ 1192 id: d.id, 1193 type: 'download_started', 1194 projectTitle: d.projectTitle, 1195 quality: d.quality, 1196 phase: 'processing', 1197 progress: 5, 1198 message: 'Export started', 1199 }); 1200 }); 1201 window.addEventListener('arturum:export-progress', (e) => { 1202 const d = e.detail || {}; 1203 updateNotificationProgress(d.id, { 1204 progress: d.progress, 1205 phase: d.phase || 'processing', 1206 message: d.message, 1207 }); 1208 }); 1209 window.addEventListener('arturum:export-done', (e) => { 1210 const d = e.detail || {}; 1211 updateNotificationProgress(d.id, { 1212 type: 'download_completed', 1213 downloadUrl: d.downloadUrl, 1214 filename: d.filename, 1215 quality: d.quality, 1216 progress: 100, 1217 timestamp: Date.now(), 1218 }); 1219 }); 1220 window.addEventListener('arturum:export-error', (e) => { 1221 const d = e.detail || {}; 1222 updateNotificationProgress(d.id, { 1223 type: 'download_failed', 1224 message: d.message || 'Export failed', 1225 timestamp: Date.now(), 1226 }); 1227 }); 1228 1229 // Fetch unread count from server (if logged in) 1230 if (getAuthToken()) { 1231 fetchUnreadCount(); 1232 1233 // Poll unread count every 15 seconds for near-real-time feel 1234 if (pollIntervalId) clearInterval(pollIntervalId); 1235 pollIntervalId = setInterval(() => { 1236 if (getAuthToken()) fetchUnreadCount(); 1237 }, POLL_INTERVAL_MS); 1238 } 1239 1240 // Live generation status from any page (see LIVE JOB WATCHER) 1241 startJobWatcher(); 1242 1243 updateBellBadge(); 1244} 1245 1246// ============================================================================ 1247// LIVE JOB WATCHER 1248// ============================================================================ 1249// Generations started anywhere (Create, Scene Viewer, Actors, modals, another 1250// tab) show up in the Generations tab and flip to "complete" the moment the 1251// job finishes â no page refresh. There is no idle polling: the watcher only 1252// polls while the user has queued/processing jobs, and is woken by any 1253// generation request this page sends, by page load, and by returning to the tab. 1254 1255const JOB_POLL_MS = 4000; 1256const ACTIVE_JOB_STATUSES = new Set(['queued', 'pending', 'processing', 'running']); 1257// POSTs that create ai_generation_jobs rows (see workers/api router) 1258const GENERATION_REQUEST = /^\/api\/(ai\/generate$|music\/|tts\/|compose)/; 1259 1260const knownJobStatus = new Map(); // jobId â last seen status 1261let jobCheckTimer = null; 1262let jobCheckInFlight = false; 1263 1264function scheduleJobCheck(delayMs) { 1265 if (jobCheckTimer) clearTimeout(jobCheckTimer); 1266 jobCheckTimer = setTimeout(checkJobs, delayMs); 1267} 1268 1269function wasActive(jobId) { 1270 if (ACTIVE_JOB_STATUSES.has(knownJobStatus.get(jobId))) return true; 1271 // Started in an earlier page load / another tab and recorded in storage 1272 return notifications.some(n => n.type === 'generation_started' && String(n.jobId) === String(jobId)); 1273} 1274 1275function jobThumbnail(job) { 1276 if (job.thumbnail_url) return job.thumbnail_url; 1277 const url = job.result_url || ''; 1278 if (/\.(mp4|webm|mov|m4v)(\?|$)/i.test(url)) return getOptimizedVideoPoster(url, 'thumb') || ''; 1279 return url; 1280} 1281 1282async function checkJobs() { 1283 jobCheckTimer = null; 1284 if (!getAuthToken() || jobCheckInFlight) return; 1285 jobCheckInFlight = true; 1286 let activeCount = 0; 1287 try { 1288 const data = await apiFetch('/api/users/me/jobs?limit=15'); 1289 const jobs = Array.isArray(data?.jobs) ? data.jobs : []; 1290 let finished = false;
1291 for (const job of jobs) { 1292 const isActive = ACTIVE_JOB_STATUSES.has(job.status); 1293 if (isActive) { 1294 activeCount++; 1295 addAndSaveNotification({ 1296 type: 'generation_started', 1297 jobId: job.id, 1298 model: job.model || 'AI Generation', 1299 credits: job.credits_charged || 0, 1300 prompt: job.prompt?.substring(0, 100), 1301 timestamp: Date.parse(job.created_at) || Date.now(), 1302 }); 1303 } else if ((job.status === 'completed' || job.status === 'failed') && wasActive(job.id)) { 1304 finished = true; 1305 addAndSaveNotification(job.status === 'completed' ? { 1306 type: 'generation_completed', 1307 jobId: job.id, 1308 model: job.model || 'AI Generation', 1309 prompt: job.prompt, 1310 thumbnailUrl: jobThumbnail(job), 1311 credits: job.credits_charged || 0, 1312 } : { 1313 type: 'generation_failed', 1314 jobId: job.id, 1315 model: job.model || 'AI Generation', 1316 credits: job.credits_charged || 0, 1317 }); 1318 } 1319 if (job.status === 'cancelled' && wasActive(job.id)) { 1320 // Drop the stale "started" card 1321 notifications = notifications.filter(n => !(n.type === 'generation_started' && String(n.jobId) === String(job.id))); 1322 saveNotifications(); 1323 if (isOpen && activeTab === 'generation') renderGenerationList(); 1324 updateBellBadge(); 1325 } 1326 knownJobStatus.set(job.id, job.status); 1327 } 1328 if (finished) { 1329 pulseNotificationBadge(); 1330 // Failed jobs are refunded server-side â show the updated balance 1331 refreshCreditBalance().catch(() => {}); 1332 } 1333 } catch (e) { 1334 console.warn('Job watcher check failed:', e); 1335 } finally { 1336 jobCheckInFlight = false; 1337 } 1338 if (activeCount > 0 && document.visibilityState !== 'hidden') scheduleJobCheck(JOB_POLL_MS); 1339} 1340 1341function startJobWatcher() { 1342 // Notice generation requests from any page, whatever client they use. 1343 if (!window.__arturumGenerationFetchHook) { 1344 window.__arturumGenerationFetchHook = true; 1345 const apiOrigin = new URL(API_BASE_URL).origin; 1346 const originalFetch = window.fetch.bind(window); 1347 window.fetch = async (input, init) => { 1348 const response = await originalFetch(input, init);
1349 try { 1350 const method = String(init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); 1351 if (method === 'POST' && response.ok) { 1352 const url = new URL(typeof input === 'string' ? input : (input?.url || String(input)), window.location.href); 1353 if (url.origin === apiOrigin && GENERATION_REQUEST.test(url.pathname)) scheduleJobCheck(1500); 1354 } 1355 } catch { /* observing only â never affect the request */ } 1356 return response; 1357 }; 1358 } 1359 1360 document.addEventListener('visibilitychange', () => { 1361 if (document.visibilityState === 'visible') scheduleJobCheck(300); 1362 }); 1363 1364 // Keep tabs in sync: another tab saved notifications 1365 window.addEventListener('storage', (e) => { 1366 if (e.key !== 'arturum_notifications' || !e.newValue) return; 1367 try { 1368 notifications = JSON.parse(e.newValue); 1369 if (isOpen && activeTab === 'generation') renderGenerationList(); 1370 else if (isOpen && activeTab === 'download') renderDownloadList(); 1371 updateBellBadge(); 1372 } catch { /* ignore malformed */ } 1373 }); 1374 1375 // Pick up jobs still running from before this page load 1376 scheduleJobCheck(2000); 1377} 1378 1379function saveNotifications() { 1380 try { 1381 localStorage.setItem('arturum_notifications', JSON.stringify(notifications.slice(0, MAX_NOTIFICATIONS))); 1382 } catch (e) { 1383 console.error('Failed to save notifications:', e); 1384 } 1385} 1386 1387// Auto-save on changes 1388const originalAddNotification = addGenerationNotification; 1389export { originalAddNotification }; 1390 1391const _addGenerationNotification = addGenerationNotification; 1392export function addAndSaveNotification(notification) { 1393 const owner = panelOwner(); 1394 if (owner) return owner.addGenerationNotification(notification); 1395 _addGenerationNotification(notification); 1396 saveNotifications(); 1397} 1398 1399// ============================================================================ 1400// EXPORTS 1401// ============================================================================ 1402 1403export default { 1404 initNotificationPanel, 1405 openNotificationPanel, 1406 closeNotificationPanel, 1407 toggleNotificationPanel, 1408 addGenerationNotification: addAndSaveNotification, 1409 clearNotifications, 1410};
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.