domma-cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Domma Celebrations - Main Module
3
+ *
4
+ * Year-round visual celebration effects system supporting 8 celebration themes.
5
+ * Auto-detects current celebration based on date or allows manual theme selection.
6
+ *
7
+ * @module celebrations
8
+ */
9
+
10
+ import { CanvasManager } from './core/canvas.js';
11
+ import { PhysicsEngine, updateParticlePhysics, updateMovingParticle } from './core/physics.js';
12
+ import { createParticle } from './core/particles.js';
13
+
14
+ /**
15
+ * Main Celebrations Effect class
16
+ */
17
+ export class CelebrationsEffect {
18
+ // Theme date ranges: [[startMonth, startDay], [endMonth, endDay]]
19
+ // 5-day lead-up, ends at midnight on celebration day
20
+ static themes = {
21
+ christmas: {
22
+ module: './themes/christmas.js',
23
+ dates: [[12, 1], [1, 1]], // All December + New Year's Day
24
+ displayName: 'Christmas',
25
+ emoji: '🎄'
26
+ },
27
+ halloween: {
28
+ module: './themes/halloween.js',
29
+ dates: [[10, 26], [10, 31]], // Oct 26-31 (midnight)
30
+ displayName: 'Halloween',
31
+ emoji: '🎃'
32
+ },
33
+ valentines: {
34
+ module: './themes/valentines.js',
35
+ dates: [[2, 9], [2, 14]], // Feb 9-14 (midnight)
36
+ displayName: 'Valentine\'s Day',
37
+ emoji: '💕'
38
+ },
39
+ 'guy-fawkes': {
40
+ module: './themes/guy-fawkes.js',
41
+ dates: [[11, 1], [11, 5]], // Nov 1-5 (midnight)
42
+ displayName: 'Guy Fawkes Night',
43
+ emoji: '🎆'
44
+ },
45
+ 'st-patricks': {
46
+ module: './themes/st-patricks.js',
47
+ dates: [[3, 12], [3, 17]], // Mar 12-17 (midnight)
48
+ displayName: 'St Patrick\'s Day',
49
+ emoji: '☘️'
50
+ },
51
+ 'st-andrews': {
52
+ module: './themes/st-andrews.js',
53
+ dates: [[11, 25], [11, 30]], // Nov 25-30 (midnight)
54
+ displayName: 'St Andrew\'s Day',
55
+ emoji: '🏴󠁧󠁢󠁳󠁣󠁴󠁿'
56
+ },
57
+ 'st-davids': {
58
+ module: './themes/st-davids.js',
59
+ dates: [[2, 24], [3, 1]], // Feb 24 - Mar 1 (midnight)
60
+ displayName: 'St David\'s Day',
61
+ emoji: '🏴󠁧󠁢󠁷󠁬󠁳󠁿'
62
+ },
63
+ 'st-georges': {
64
+ module: './themes/st-georges.js',
65
+ dates: [[4, 18], [4, 23]], // Apr 18-23 (midnight)
66
+ displayName: 'St George\'s Day',
67
+ emoji: '🏴󠁧󠁢󠁥󠁮󠁧󠁿'
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Check if current date is within a celebration period
73
+ */
74
+ static isDateInRange(startMonth, startDay, endMonth, endDay) {
75
+ const now = new Date();
76
+ const month = now.getMonth() + 1; // 1-12
77
+ const day = now.getDate();
78
+
79
+ // Handle year wrapping (e.g., Dec 1 - Jan 1)
80
+ if (endMonth < startMonth) {
81
+ return (
82
+ (month === startMonth && day >= startDay) ||
83
+ (month > startMonth) ||
84
+ (month < startMonth && month <= endMonth) ||
85
+ (month === endMonth && day <= endDay)
86
+ );
87
+ }
88
+
89
+ // Normal date range
90
+ return (
91
+ (month > startMonth || (month === startMonth && day >= startDay)) &&
92
+ (month < endMonth || (month === endMonth && day <= endDay))
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Auto-detect current theme based on date
98
+ * @returns {string|null} Theme name or null if no celebration is active
99
+ */
100
+ static getCurrentTheme() {
101
+ for (const [themeName, themeData] of Object.entries(CelebrationsEffect.themes)) {
102
+ const [[startMonth, startDay], [endMonth, endDay]] = themeData.dates;
103
+ if (CelebrationsEffect.isDateInRange(startMonth, startDay, endMonth, endDay)) {
104
+ return themeName;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Check if any celebration is currently active
112
+ * @returns {boolean}
113
+ */
114
+ static isCelebrationSeason() {
115
+ return CelebrationsEffect.getCurrentTheme() !== null;
116
+ }
117
+
118
+ /**
119
+ * Get all available themes
120
+ * @returns {Object} Themes object
121
+ */
122
+ static getThemes() {
123
+ return CelebrationsEffect.themes;
124
+ }
125
+
126
+ /**
127
+ * Create a new celebrations effect
128
+ * @param {Object} options - Configuration options
129
+ * @param {string} options.theme - Theme name (or 'auto' for auto-detection)
130
+ * @param {string} options.intensity - Intensity level ('light', 'medium', 'heavy')
131
+ * @param {boolean} options.enabled - Whether to start enabled
132
+ * @param {number} options.zIndex - Canvas z-index
133
+ */
134
+ constructor(options = {}) {
135
+ this.options = {
136
+ theme: options.theme || 'auto',
137
+ intensity: options.intensity || 'medium',
138
+ enabled: options.enabled !== undefined ? options.enabled : true,
139
+ zIndex: options.zIndex || 999
140
+ };
141
+
142
+ this.canvasManager = null;
143
+ this.physicsEngine = null;
144
+ this.themeModule = null;
145
+ this.currentTheme = null;
146
+
147
+ this.particles = [];
148
+ this.specialParticles = [];
149
+
150
+ this.animationFrame = null;
151
+ this.lastTime = 0;
152
+ this.running = false;
153
+ this.initialized = false;
154
+ }
155
+
156
+ /**
157
+ * Initialize the celebrations effect
158
+ */
159
+ async init() {
160
+ if (this.initialized) {
161
+ console.warn('[Celebrations] Already initialized');
162
+ return;
163
+ }
164
+
165
+ console.log('[Celebrations] Initializing...');
166
+
167
+ // Determine theme
168
+ let themeName = this.options.theme;
169
+ if (themeName === 'auto') {
170
+ themeName = CelebrationsEffect.getCurrentTheme();
171
+ if (!themeName) {
172
+ console.log('[Celebrations] No active celebration detected');
173
+ return;
174
+ }
175
+ }
176
+
177
+ // Validate theme
178
+ if (!CelebrationsEffect.themes[themeName]) {
179
+ console.error(`[Celebrations] Unknown theme: ${themeName}`);
180
+ return;
181
+ }
182
+
183
+ this.currentTheme = themeName;
184
+ console.log(`[Celebrations] Loading theme: ${themeName}`);
185
+
186
+ // Load theme module
187
+ try {
188
+ const themeData = CelebrationsEffect.themes[themeName];
189
+ const module = await import(themeData.module);
190
+ this.themeModule = module.default;
191
+ console.log(`[Celebrations] Theme loaded: ${this.themeModule.displayName}`);
192
+ } catch (error) {
193
+ console.error(`[Celebrations] Failed to load theme module: ${error.message}`);
194
+ return;
195
+ }
196
+
197
+ // Initialize canvas
198
+ this.canvasManager = new CanvasManager({
199
+ canvasId: 'celebrations-canvas',
200
+ zIndex: this.options.zIndex
201
+ });
202
+ this.canvasManager.create();
203
+
204
+ // Initialize physics
205
+ this.physicsEngine = new PhysicsEngine({
206
+ gustInterval: [5000, 15000],
207
+ gustStrength: [-2, 2]
208
+ });
209
+
210
+ // Setup resize handler
211
+ window.addEventListener('resize', () => {
212
+ this.canvasManager.resize();
213
+ this.resetParticles();
214
+ });
215
+
216
+ // Initialize particles
217
+ this.resetParticles();
218
+
219
+ this.initialized = true;
220
+ console.log('[Celebrations] Initialized successfully');
221
+
222
+ // Auto-start if enabled
223
+ if (this.options.enabled) {
224
+ this.start();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Reset particles based on current intensity
230
+ */
231
+ resetParticles() {
232
+ const config = this.themeModule.intensityConfig[this.options.intensity];
233
+ const width = this.canvasManager.canvas.width;
234
+ const height = this.canvasManager.canvas.height;
235
+
236
+ // Clear existing particles
237
+ this.particles = [];
238
+ this.specialParticles = [];
239
+
240
+ // Determine initial particle count (some themes may want to start with fewer particles)
241
+ const initialRatio = config.initialParticleRatio !== undefined ? config.initialParticleRatio : 1.0;
242
+ const initialCount = Math.floor(config.count * initialRatio);
243
+
244
+ // Create falling particles (theme-specific method or generic)
245
+ if (this.themeModule.createFallingParticle) {
246
+ for (let i = 0; i < initialCount; i++) {
247
+ const particle = this.themeModule.createFallingParticle(width, height, config);
248
+ particle.y = Math.random() * height; // Spread across screen initially
249
+ this.particles.push(particle);
250
+ }
251
+ } else {
252
+ // Fallback to generic particles
253
+ for (let i = 0; i < initialCount; i++) {
254
+ const particle = createParticle(config, width, height);
255
+ particle.y = Math.random() * height;
256
+ this.particles.push(particle);
257
+ }
258
+ }
259
+
260
+ // Create initial static decorations (theme-specific)
261
+ if (this.themeModule.createInitialDecorations) {
262
+ const decorations = this.themeModule.createInitialDecorations(width, height, config);
263
+ this.specialParticles.push(...decorations);
264
+ console.log(`[Celebrations] Created ${decorations.length} decorations`);
265
+ }
266
+
267
+ console.log(`[Celebrations] Created ${initialCount}/${config.count} particles (${this.options.intensity}, ${Math.round(initialRatio * 100)}% initial)`);
268
+ }
269
+
270
+ /**
271
+ * Start the animation
272
+ */
273
+ start() {
274
+ if (!this.initialized) {
275
+ console.warn('[Celebrations] Cannot start - not initialized');
276
+ return;
277
+ }
278
+
279
+ if (this.running) {
280
+ console.warn('[Celebrations] Already running');
281
+ return;
282
+ }
283
+
284
+ console.log('[Celebrations] Starting animation');
285
+ this.running = true;
286
+ this.lastTime = performance.now();
287
+ this.animate();
288
+ }
289
+
290
+ /**
291
+ * Pause the animation
292
+ */
293
+ pause() {
294
+ if (!this.running) {
295
+ return;
296
+ }
297
+
298
+ console.log('[Celebrations] Pausing animation');
299
+ this.running = false;
300
+
301
+ if (this.animationFrame) {
302
+ cancelAnimationFrame(this.animationFrame);
303
+ this.animationFrame = null;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Resume the animation
309
+ */
310
+ resume() {
311
+ if (this.running) {
312
+ return;
313
+ }
314
+
315
+ this.start();
316
+ }
317
+
318
+ /**
319
+ * Destroy the effect
320
+ */
321
+ destroy() {
322
+ console.log('[Celebrations] Destroying effect');
323
+ this.pause();
324
+
325
+ if (this.canvasManager && this.canvasManager.canvas) {
326
+ this.canvasManager.canvas.remove();
327
+ }
328
+
329
+ this.particles = [];
330
+ this.specialParticles = [];
331
+ this.canvasManager = null;
332
+ this.physicsEngine = null;
333
+ this.themeModule = null;
334
+ this.initialized = false;
335
+ }
336
+
337
+ /**
338
+ * Change intensity
339
+ */
340
+ setIntensity(intensity) {
341
+ if (!['light', 'medium', 'heavy'].includes(intensity)) {
342
+ console.error(`[Celebrations] Invalid intensity: ${intensity}`);
343
+ return;
344
+ }
345
+
346
+ console.log(`[Celebrations] Changing intensity to: ${intensity}`);
347
+ this.options.intensity = intensity;
348
+ this.resetParticles();
349
+ }
350
+
351
+ /**
352
+ * Change theme
353
+ */
354
+ async setTheme(themeName) {
355
+ console.log(`[Celebrations] Changing theme to: ${themeName}`);
356
+
357
+ const wasRunning = this.running;
358
+ this.pause();
359
+
360
+ // Wait for current animation frame to complete before clearing
361
+ await new Promise(resolve => setTimeout(resolve, 20));
362
+
363
+ this.initialized = false;
364
+
365
+ // Clear canvas and particles from previous theme
366
+ if (this.canvasManager) {
367
+ this.canvasManager.clear();
368
+ }
369
+ this.particles = [];
370
+ this.specialParticles = [];
371
+
372
+ // Clear again to be absolutely sure
373
+ if (this.canvasManager) {
374
+ this.canvasManager.clear();
375
+ }
376
+
377
+ this.options.theme = themeName;
378
+ await this.init();
379
+
380
+ if (wasRunning) {
381
+ this.start();
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Main animation loop
387
+ */
388
+ animate() {
389
+ if (!this.running) {
390
+ return;
391
+ }
392
+
393
+ const currentTime = performance.now();
394
+ const deltaTime = currentTime - this.lastTime;
395
+ this.lastTime = currentTime;
396
+
397
+ // Clear canvas
398
+ this.canvasManager.clear();
399
+
400
+ const ctx = this.canvasManager.ctx;
401
+ const width = this.canvasManager.canvas.width;
402
+ const height = this.canvasManager.canvas.height;
403
+ const config = this.themeModule.intensityConfig[this.options.intensity];
404
+
405
+ // Update wind
406
+ this.physicsEngine.updateWind(currentTime);
407
+ const windForce = this.physicsEngine.getWindForce();
408
+
409
+ // Gradually add particles until reaching target count (for themes with initialParticleRatio < 1)
410
+ if (this.particles.length < config.count && Math.random() < 0.05) {
411
+ if (this.themeModule.createFallingParticle) {
412
+ const newParticle = this.themeModule.createFallingParticle(width, height, config);
413
+ // For new particles added during animation, start them off-screen
414
+ if (newParticle.y === undefined || newParticle.y < 0) {
415
+ newParticle.y = -20;
416
+ }
417
+ this.particles.push(newParticle);
418
+ }
419
+ }
420
+
421
+ // Update and render particles
422
+ this.particles.forEach((particle, index) => {
423
+ // Update physics
424
+ updateParticlePhysics(particle, deltaTime, windForce);
425
+
426
+ // Recycle particle if off-screen
427
+ if (particle.y > height + 50) {
428
+ particle.y = -20;
429
+ particle.x = Math.random() * width;
430
+ }
431
+ if (particle.x < -50) {
432
+ particle.x = width + 50;
433
+ }
434
+ if (particle.x > width + 50) {
435
+ particle.x = -50;
436
+ }
437
+
438
+ // Render particle (theme-specific)
439
+ // For now, render as generic snowflake-like particle
440
+ // Theme modules should provide custom rendering
441
+ if (this.themeModule.drawSnowflake && particle.type === 'snowflake') {
442
+ this.themeModule.drawSnowflake(ctx, particle);
443
+ } else if (this.themeModule['draw' + this.capitalizeFirst(particle.type)]) {
444
+ const drawMethod = 'draw' + this.capitalizeFirst(particle.type);
445
+ this.themeModule[drawMethod](ctx, particle, currentTime);
446
+ } else {
447
+ // Fallback generic rendering
448
+ ctx.save();
449
+ ctx.globalAlpha = particle.opacity;
450
+ ctx.fillStyle = this.themeModule.colors?.primary || '#ffffff';
451
+ ctx.beginPath();
452
+ ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
453
+ ctx.fill();
454
+ ctx.restore();
455
+ }
456
+ });
457
+
458
+ // Theme-specific special particle updates (firework explosions, etc.)
459
+ if (this.themeModule.updateSpecialParticles) {
460
+ this.themeModule.updateSpecialParticles(this.specialParticles, deltaTime, width, height);
461
+ }
462
+
463
+ // Update and render special particles (decorations)
464
+ this.specialParticles = this.specialParticles.filter(particle => {
465
+ if (!particle.active) {
466
+ return false;
467
+ }
468
+
469
+ // Update moving particles
470
+ if (!particle.static) {
471
+ updateMovingParticle(particle, deltaTime, currentTime);
472
+
473
+ // Remove if off-screen (wider tolerance for large particles like trains)
474
+ const offscreenMargin = particle.type === 'train' ? 600 : 200;
475
+ if (particle.x < -offscreenMargin || particle.x > width + offscreenMargin) {
476
+ particle.active = false;
477
+ return false;
478
+ }
479
+ }
480
+
481
+ // Safety check: validate particle properties before rendering
482
+ if (!isFinite(particle.x) || !isFinite(particle.y) || (particle.size && !isFinite(particle.size))) {
483
+ console.warn('[Celebrations] Invalid particle values, removing:', particle.type);
484
+ particle.active = false;
485
+ return false;
486
+ }
487
+
488
+ // Render (theme-specific)
489
+ const typeName = this.capitalizeFirst(particle.type);
490
+ const drawMethod = 'draw' + typeName;
491
+ if (this.themeModule[drawMethod]) {
492
+ this.themeModule[drawMethod](ctx, particle, currentTime);
493
+ }
494
+
495
+ return true;
496
+ });
497
+
498
+ // Spawn special particles
499
+ if (this.themeModule.spawnSpecialParticle) {
500
+ const newParticle = this.themeModule.spawnSpecialParticle(
501
+ this.specialParticles,
502
+ width,
503
+ height,
504
+ config
505
+ );
506
+ if (newParticle) {
507
+ this.specialParticles.push(newParticle);
508
+ }
509
+ }
510
+
511
+ // Call theme-specific global drawing (e.g., lightning)
512
+ if (this.themeModule.drawGlobalEffects) {
513
+ this.themeModule.drawGlobalEffects(ctx, currentTime, width, height);
514
+ }
515
+
516
+ // Continue animation
517
+ this.animationFrame = requestAnimationFrame(() => this.animate());
518
+ }
519
+
520
+ /**
521
+ * Helper: Capitalize first letter and letters after hyphens
522
+ * Converts hyphenated names to camelCase: 'sparkler-bundle' → 'SparklerBundle'
523
+ */
524
+ capitalizeFirst(str) {
525
+ if (!str) return '';
526
+ // First, capitalize letters after hyphens and remove hyphens
527
+ // 'sparkler-bundle' → 'sparklerBundle'
528
+ const camelCase = str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
529
+ // Then capitalize the first letter: 'sparklerBundle' → 'SparklerBundle'
530
+ return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
531
+ }
532
+ }
533
+
534
+ // Export for direct use
535
+ export default CelebrationsEffect;