PageSourceSearch

https://www.arturum.ai/js/components/NotificationPanel.js?v=1843000000

js arturum.ai collected 2026-10-05 15:18:49 UTC 55,158 bytes, 1,410 lines download raw bytes

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 &amp; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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.