PageSourceSearch

https://www.mtgproxy.net/src/utils/NotificationManager.js

js mtgproxy.net collected 2026-09-28 07:07:33 UTC 18,590 bytes, 617 lines download raw bytes

1/**
2 * NotificationManager - Handles update notifications with localStorage persistence
3 */
4export class NotificationManager {
5  constructor() {
6    this.notifications = [];
7    this.container = null;
8    this.storageKey = 'mtgproxy_dismissed_notifications';
9  }
10
11  /**
12   * Initialize the notification system
13   */
14  init() {
15    this.createContainer();
16    this.addStyles();
17
18    // Define notifications to show
19    this.notifications = [
20      {
21        id: 'onboarding-prompt-2025',
22        title: 'New to mtgproxy.net?',
23        message: '',
24        actionText: 'Get Started',
25        action: () => {
26          if (window.showOnboarding) window.showOnboarding();
27        },
28        priority: -1
29      },
30      {
31        id: 'dfc-support-2024',
32        title: 'Double Faced Cards',
33        message: 'are now supported!',
34        priority: 1
35      },
36      {
37        id: 'test-print-2024',
38        title: 'Test Print Feature',
39        message: 'Try a test print ',
40        linkText: 'here',
41        linkUrl: '/printing-guide#test-print',
42        priority: 2
43      },
44      {
45        id: 'recommended-products-2025',
46        title: 'Recommended Products',
47        message: 'Check out our affiliate links ',
48        linkText: 'here',
49        linkUrl: '/printing-guide#recommended-products',
50        priority: 3
51      }
52    ];
53
54    // Show notifications that haven't been dismissed
55    this.showPendingNotifications();
56  }
57
58  /**
59   * Create notification container
60   */
61  createContainer() {
62    this.container = document.createElement('div');
63    this.container.id = 'notification-container';
64    document.body.appendChild(this.container);
65  }
66
67  /**
68   * Get dismissed notifications from localStorage
69   */
70  getDismissedNotifications() {
71    try {
72      const dismissed = localStorage.getItem(this.storageKey);
73      return dismissed ? JSON.parse(dismissed) : [];
74    } catch (error) {
75      console.error('Failed to load dismissed notifications:', error);
76      return [];
77    }
78  }
79
80  /**
81   * Save dismissed notification to localStorage
82   */
83  saveNotificationDismissal(notificationId) {
84    try {
85      const dismissed = this.getDismissedNotifications();
86      if (!dismissed.includes(notificationId)) {
87        dismissed.push(notificationId);
88        localStorage.setItem(this.storageKey, JSON.stringify(dismissed));
89      }
90    } catch (error) {
91      console.error('Failed to save notification dismissal:', error);
92    }
93  }
94
95  /**
96   * Show all pending (non-dismissed) notifications
97   */
98  showPendingNotifications() {
99    const dismissed = this.getDismissedNotifications();
100
101    // Sort by priority and show non-dismissed notifications
102    this.notifications
103      .filter(notif => !dismissed.includes(notif.id))
104      .sort((a, b) => a.priority - b.priority)
105      .forEach((notif, index) => {
106        // Stagger animation slightly for multiple notifications
107        setTimeout(() => {
108          this.showNotification(notif);
109        }, index * 150);
110      });
111  }
112
113  /**
114   * Show a single notification
115   */
116  showNotification(notification) {
117    const notifElement = document.createElement('div');
118    notifElement.className = 'update-notification';
119    notifElement.dataset.notificationId = notification.id;
120
121    // Create close button (static SVG, safe to use innerHTML)
122    const closeBtn = document.createElement('button');
123    closeBtn.className = 'notification-close';
124    closeBtn.setAttribute('aria-label', 'Dismiss notification');
125    closeBtn.innerHTML = `
126      <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
127        <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
128        <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
129      </svg>
130    `;
131
132    // Create content container (use textContent for user data - XSS safe)
133    const content = document.createElement('div');
134    content.className = 'notification-content';
135
136    const titleEl = document.createElement('strong');
137    titleEl.textContent = notification.title; // XSS-safe
138
139    const messageEl = document.createElement('span');
140    messageEl.textContent = notification.message; // XSS-safe
141
142    // Add link if provided
143    if (notification.linkText && notification.linkUrl) {
144      const linkEl = document.createElement('a');
145      linkEl.href = notification.linkUrl;
146      linkEl.textContent = notification.linkText;
147      linkEl.className = 'notification-link';
148      messageEl.appendChild(linkEl);
149    }
150
151    // Add action button if provided
152    if (notification.actionText && notification.action) {
153      const actionBtn = document.createElement('button');
154      actionBtn.textContent = notification.actionText;
155      actionBtn.className = 'notification-action';
156      actionBtn.addEventListener('click', () => {
157        notification.action();
158        this.dismissNotification(notification.id, notifElement);
159      });
160      messageEl.appendChild(actionBtn);
161    }
162
163    content.appendChild(titleEl);
164    content.appendChild(messageEl);
165
166    notifElement.appendChild(closeBtn);
167    notifElement.appendChild(content);
168
169    // Add close handler
170    closeBtn.addEventListener('click', () => {
171      this.dismissNotification(notification.id, notifElement);
172    });
173
174    // Add to container
175    this.container.appendChild(notifElement);
176
177    // Trigger animation after a brief delay
178    setTimeout(() => {
179      notifElement.classList.add('show');
180    }, 10);
181  }
182
183  /**
184   * Dismiss and remove a notification
185   */
186  dismissNotification(notificationId, element) {
187    // Save dismissal
188    this.saveNotificationDismissal(notificationId);
189
190    // Animate out
191    element.classList.add('dismissing');
192
193    // Remove from DOM after animation
194    setTimeout(() => {
195      element.remove();
196    }, 300);
197  }
198
199  /**
200   * Show an error notification. Always persists until manually dismissed —
201   * errors (e.g. "card not found") must stay readable, so this intentionally
202   * has no auto-dismiss option at all (unlike showSuccess/showInfo).
203   * SECURITY: Uses textContent to prevent XSS attacks from user input
204   */
205  showError(title, message) {
206    const errorNotif = document.createElement('div');
207    errorNotif.className = 'update-notification error-notification';
208
209    // Create close button (static SVG, safe to use innerHTML)
210    const closeBtn = document.createElement('button');
211    closeBtn.className = 'notification-close';
212    closeBtn.setAttribute('aria-label', 'Dismiss notification');
213    closeBtn.innerHTML = `
214      <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
215        <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
216        <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
217      </svg>
218    `;
219
220    // Create content container (use textContent for user data - XSS 
220safe)
221    const content = document.createElement('div');
222    content.className = 'notification-content';
223
224    const titleEl = document.createElement('strong');
225    titleEl.textContent = title; // XSS-safe: escapes HTML entities
226
227    const messageEl = document.createElement('span');
228    messageEl.textContent = message; // XSS-safe: escapes HTML entities
229
230    content.appendChild(titleEl);
231    content.appendChild(messageEl);
232
233    errorNotif.appendChild(closeBtn);
234    errorNotif.appendChild(content);
235
236    // Add close handler
237    const dismissFn = () => {
238      errorNotif.classList.add('dismissing');
239      setTimeout(() => errorNotif.remove(), 300);
240    };
241    closeBtn.addEventListener('click', dismissFn);
242
243    // Add to container
244    if (!this.container) {
245      this.createContainer();
246    }
247    this.container.appendChild(errorNotif);
248
249    // Trigger animation
250    setTimeout(() => errorNotif.classList.add('show'), 10);
251
252    return errorNotif;
253  }
254
255  /**
256   * Show a temporary info notification (auto-dismisses after delay)
257   * SECURITY: Uses textContent to prevent XSS attacks from user input
258   */
259  showInfo(title, message, options = {}) {
260    const { autoDismissMs = 5000 } = options;
261
262    const infoNotif = document.createElement('div');
263    infoNotif.className = 'update-notification info-notification';
264
265    // Create close button (static SVG, safe to use innerHTML)
266    const closeBtn = document.createElement('button');
267    closeBtn.className = 'notification-close';
268    closeBtn.setAttribute('aria-label', 'Dismiss notification');
269    closeBtn.innerHTML = `
270      <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
271        <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
272        <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
273      </svg>
274    `;
275
276    // Create content container (use textContent for user data - XSS safe)
277    const content = document.createElement('div');
278    content.className = 'notification-content';
279
280    const titleEl = document.createElement('strong');
281    titleEl.textContent = title; // XSS-safe: escapes HTML entities
282
283    const messageEl = document.createElement('span');
284    messageEl.textContent = message; // XSS-safe: escapes HTML entities
285
286    content.appendChild(titleEl);
287    content.appendChild(messageEl);
288
289    infoNotif.appendChild(closeBtn);
290    infoNotif.appendChild(content);
291
292    // Add close handler
293    const dismissFn = () => {
294      infoNotif.classList.add('dismissing');
295      setTimeout(() => infoNotif.remove(), 300);
296    };
297    closeBtn.addEventListener('click', dismissFn);
298
299    // Add to container
300    if (!this.container) {
301      this.createContainer();
302    }
303    this.container.appendChild(infoNotif);
304
305    // Trigger animation
306    setTimeout(() => infoNotif.classList.add('show'), 10);
307
308    // Auto-dismiss after delay
309    if (autoDismissMs > 0) {
310      setTimeout(dismissFn, autoDismissMs);
311    }
312
313    return infoNotif;
314  }
315
316  /**
317   * Show a success notification with optional action button
318   * SECURITY: Uses textContent to prevent XSS attacks from user input
319   * @param {string} title - Notification title
320   * @param {string} message - Notification message
321   * @param {Object} options - Optional configuration
322   * @param {string} options.actionText - Text for action button
323   * @param {Function} options.onAction - Callback for action button
324   * @param {number} options.autoDismissMs - Auto-dismiss delay (0 = no auto-dismiss)
325   */
326  showSuccess(title, message, options = {}) {
327    const {
328      actionText = null,
329      onAction = null,
330      autoDismissMs = 0
331    } = options;
332
333    const successNotif = document.createElement('div');
334    successNotif.className = 'update-notification success-notification';
335
336    // Create close button (static SVG, safe to use innerHTML)
337    const closeBtn = document.createElement('button');
338    closeBtn.className = 'notification-close';
339    closeBtn.setAttribute('aria-label', 'Dismiss notification');
340    closeBtn.innerHTML = `
341      <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
342        <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
343        <path d="M7 7L13 13M13 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
344      </svg>
345    `;
346
347    // Create content container (use textContent for user data - XSS 
347safe)
348    const content = document.createElement('div');
349    content.className = 'notification-content';
350
351    const titleEl = document.createElement('strong');
352    titleEl.textContent = title; // XSS-safe: escapes HTML entities
353
354    const messageEl = document.createElement('span');
355    messageEl.textContent = message; // XSS-safe: escapes HTML entities
356
357    content.appendChild(titleEl);
358    content.appendChild(messageEl);
359
360    // Add action button if provided
361    if (actionText) {
362      const actionBtn = document.createElement('button');
363      actionBtn.className = 'notification-action-btn';
364      actionBtn.textContent = actionText; // XSS-safe: escapes HTML entities
365      content.appendChild(actionBtn);
366
367      // Add action button handler
368      if (onAction) {
369        actionBtn.addEventListener('click', () => {
370          onAction();
371          dismissFn();
372        });
373      }
374    }
375
376    successNotif.appendChild(closeBtn);
377    successNotif.appendChild(content);
378
379    // Add close handler
380    const dismissFn = () => {
381      successNotif.classList.add('dismissing');
382      setTimeout(() => successNotif.remove(), 300);
383    };
384    closeBtn.addEventListener('click', dismissFn);
385
386    // Add to container
387    if (!this.container) {
388      this.createContainer();
389    }
390    this.container.appendChild(successNotif);
391
392    // Trigger animation
393    setTimeout(() => successNotif.classList.add('show'), 10);
394
395    // Auto-dismiss after delay if specified
396    if (autoDismissMs > 0) {
397      setTimeout(dismissFn, autoDismissMs);
398    }
399
400    return successNotif;
401  }
402
403  /**
404   * Add notification styles
405   */
406  addStyles() {
407    const style = document.createElement('style');
408    style.textContent = `
409      #notification-container {
410        position: fixed;
411        top: 16px;
412        right: 20px;
413        z-index: 99999;
414        display: flex;
415        flex-direction: column;
416        gap: 12px;
417        pointer-events: none;
418      }
419
420      .update-notification {
421        background: var(--bg-primary, #1a1b1e);
422        border: 1px solid color-mix(in srgb, var(--accent-primary, #9333ea) 30%, transparent);
423        border-radius: 12px;
424        padding: 16px 20px;
425        padding-right: 44px;
426        min-width: 280px;
427        max-width: 320px;
428        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
429        position: relative;
430        opacity: 0;
431        visibility: hidden;
432        transform: translateX(100%) scale(0.9);
433        transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
434        pointer-events: auto;
435      }
436
437      .update-notification.show {
438        opacity: 1;
439        visibility: visible;
440        transform: translateX(0) scale(1);
441        animation: jiggle 0.5s ease 0.3s;
442      }
443
444      .update-notification.dismissing {
445        opacity: 0;
446        visibility: hidden;
447        transform: translateX(100%) scale(0.8);
448      }
449
450      #notification-container:empty {
451        display: none;
452      }
453
454      .update-notification.error-notification {
455        background: linear-gradient(135deg, #dc3545, #c82333);
456        border-color: rgba(220, 53, 69, 0.5);
457      }
458
459      .update-notification.error-notification .notification-content strong {
460        color: #ffffff;
461      }
462
463      .update-notification.success-notification {
464        background: linear-gradient(135deg, #22c55e, #16a34a);
465        border-color: rgba(34, 197, 94, 0.5);
466      }
467
468      .update-notification.success-notification .notification-content strong {
469        color: #ffffff;
470      }
471
472      .update-notification.info-notification {
473        background: linear-gradient(135deg, #3b82f6, #2563eb);
474        border-color: rgba(59, 130, 246, 0.5);
475      }
476
477      .update-notification.info-notification .notification-content strong {
478        color: #ffffff;
479      }
480
481      .notification-action-btn {
482        background: rgba(255, 255, 255, 0.2);
483        color: white;
484        border: 1px solid rgba(255, 255, 255, 0.3);
485        padding: 8px 16px;
486        border-radius: 6px;
487        cursor: pointer;
488        font-weight: 600;
489        font-size: 14px;
490        margin-top: 10px;
491        transition: all 0.2s ease;
492      }
493
494      .notification-action-btn:hover {
495        background: rgba(255, 255, 255, 0.3);
496        border-color: rgba(255, 255, 255, 0.5);
497        transform: translateY(-1px);
498      }
499
500      @keyframes jiggle {
501        0%, 100% { transform: translateX(0) rotate(0deg); }
502        25% { transform: translateX(-3px) rotate(-1deg); }
503        50% { transform: translateX(3px) rotate(1deg); }
504        75% { transform: translateX(-2px) rotate(-0.5deg); }
505      }
506
507      .notification-close {
508        position: absolute;
509        top: 12px;
510        right: 12px;
511        background: transparent;
512        border: none;
513        color: rgba(255, 255, 255, 0.6);
514        cursor: pointer;
515        padding: 4px;
516        display: flex;
517        align-items: center;
518        justify-content: center;
519        transition: all 0.2s ease;
520        border-radius: 50%;
521      }
522
523      .notification-close:hover {
524        color: rgba(255, 255, 255, 0.9);
525        background: rgba(255, 255, 255, 0.1);
526        transform: rotate(90deg);
527      }
528
529      .notification-content {
530        display: flex;
531        flex-direction: column;
532        gap: 4px;
533        color: white;
534        font-size: 14px;
535        line-height: 1.4;
536      }
537
538      .notification-content strong {
539        font-size: 15px;
540        color: var(--text-primary, #ffffff);
541        font-weight: 600;
542      }
543
544      .notification-content span {
545        color: var(--text-secondary, rgba(255, 255, 255, 0.7));
546      }
547
548      .notification-link {
549        color: var(--accent-primary, #9333ea);
550        text-decoration: underline;
551        cursor: pointer;
552        transition: color 0.2s ease;
553      }
554
555      .notification-link:hover {
556        color: var(--accent-hover, #7c3aed);
557      }
558
559      .notification-action {
560        display: inline-block;
561        margin-top: 8px;
562        padding: 8px 16px;
563        background: transparent;
564        border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.2));
565        border-radius: 8px;
566        color: var(--text-primary, #ffffff);
567        font-size: 14px;
568        font-weight: 500;
569        cursor: pointer;
570        transition: all 0.2s ease;
571      }
572
573      .notification-action:hover {
574        background: var(--accent-primary, #9333ea);
575        border-color: var(--accent-primary, #9333ea);
576      }
577
578      /* Mobile responsive */
579      @media (max-width: 640px) {
580        #notification-container {
581          top: 12px;
582          right: 12px;
583          left: 12px;
584        }
585
586        .update-notification {
587          min-width: auto;
588          max-width: none;
589        }
590      }
591
592      /* Hide notifications when onboarding modal is open */
593      body.onboarding-modal-open #notification-container {
594        display: none !important;
595      }
596    `;
597    document.head.appendChild(style);
598  }
599}
600
601// Create singleton instance
602const notificationManagerInstance = new NotificationManager();
603
604// Make globally available
605window.notificationManagerInstance = notificationManagerInstance;
606
607// Auto-initialize when DOM is ready
608if (document.readyState === 'loading') {
609  document.addEventListener('DOMContentLoaded', () => {
610    notificationManagerInstance.init();
611  });
612} else {
613  notificationManagerInstance.init();
614}
615
616export { notificationManagerInstance };
617export default NotificationManager;

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.