@phillipsharring/graspr-framework 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.
@@ -0,0 +1,20 @@
1
+ import htmx from './htmx.js';
2
+
3
+ htmx.defineExtension('json-enc', {
4
+ onEvent(name, evt) {
5
+ if (name !== 'htmx:configRequest') return;
6
+
7
+ const verb = (evt.detail.verb || 'get').toLowerCase();
8
+
9
+ // Only apply JSON content-type for non-GET requests.
10
+ if (verb === 'get') return;
11
+
12
+ evt.detail.headers['Content-Type'] = 'application/json';
13
+ evt.detail.headers['Accept'] = 'application/json';
14
+ },
15
+
16
+ encodeParameters(xhr, parameters) {
17
+ xhr.overrideMimeType('application/json');
18
+ return JSON.stringify(parameters);
19
+ },
20
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Click Burst Effect
3
+ * Creates a visual feedback burst (expanding ring) on click events.
4
+ */
5
+
6
+ const BURST_DURATION_MS = 500;
7
+ const BURST_SIZE_START = 20;
8
+ const BURST_SIZE_END = 80;
9
+
10
+ /**
11
+ * Create and animate a burst element at the given coordinates.
12
+ * @param {number} x - X coordinate (client)
13
+ * @param {number} y - Y coordinate (client)
14
+ * @param {Object} options - Optional overrides
15
+ * @param {string} options.color - RGB color for the burst, e.g. "255, 200, 50" (default: gold)
16
+ * @param {number} options.opacity - Peak opacity 0-1 (default: 0.5)
17
+ * @param {number} options.duration - Animation duration in ms (default: 500)
18
+ * @param {number} options.startSize - Initial size in px (default: 20)
19
+ * @param {number} options.endSize - Final size in px (default: 80)
20
+ */
21
+ export function createBurst(x, y, options = {}) {
22
+ const {
23
+ color = '255, 200, 50',
24
+ opacity = 0.5,
25
+ duration = BURST_DURATION_MS,
26
+ startSize = BURST_SIZE_START,
27
+ endSize = BURST_SIZE_END,
28
+ } = options;
29
+
30
+ // Build radial gradient: hollow center, solid ring, soft outer edge
31
+ const gradient = `radial-gradient(circle,
32
+ transparent 0%,
33
+ transparent 40%,
34
+ rgba(${color}, ${opacity}) 50%,
35
+ rgba(${color}, ${opacity}) 93%,
36
+ transparent 100%
37
+ )`;
38
+
39
+ const burst = document.createElement('div');
40
+ burst.className = 'click-burst';
41
+ burst.style.cssText = `
42
+ position: fixed;
43
+ left: ${x}px;
44
+ top: ${y}px;
45
+ width: ${startSize}px;
46
+ height: ${startSize}px;
47
+ margin-left: ${-startSize / 2}px;
48
+ margin-top: ${-startSize / 2}px;
49
+ border-radius: 50%;
50
+ background: ${gradient};
51
+ pointer-events: none;
52
+ z-index: 99999;
53
+ opacity: 0;
54
+ transform: scale(1);
55
+ transition: opacity ${duration * 0.15}ms ease-out, transform ${duration}ms ease-out, width ${duration}ms ease-out, height ${duration}ms ease-out, margin ${duration}ms ease-out;
56
+ `;
57
+
58
+ document.body.appendChild(burst);
59
+
60
+ // Force layout to apply initial styles before animating
61
+ void burst.getBoundingClientRect();
62
+
63
+ // Animate: fade in fast, grow, then fade out
64
+ burst.style.opacity = '1';
65
+ burst.style.width = `${endSize}px`;
66
+ burst.style.height = `${endSize}px`;
67
+ burst.style.marginLeft = `${-endSize / 2}px`;
68
+ burst.style.marginTop = `${-endSize / 2}px`;
69
+ burst.style.transform = 'scale(1)';
70
+
71
+ // Fade out after growing
72
+ setTimeout(() => {
73
+ burst.style.opacity = '0';
74
+ }, duration * 0.4);
75
+
76
+ // Remove element after animation completes
77
+ setTimeout(() => {
78
+ burst.remove();
79
+ }, duration);
80
+ }
81
+
82
+ /**
83
+ * Attach click burst to an element.
84
+ * @param {HTMLElement} element - The element to attach the burst handler to
85
+ * @param {Object} options - Options passed to createBurst
86
+ * @returns {Function} - Cleanup function to remove the listener
87
+ */
88
+ export function attachClickBurst(element, options = {}) {
89
+ const handler = (e) => {
90
+ createBurst(e.clientX, e.clientY, options);
91
+ };
92
+ element.addEventListener('click', handler);
93
+ return () => element.removeEventListener('click', handler);
94
+ }
95
+
96
+ /**
97
+ * Auto-attach click burst to elements with data-click-burst attribute.
98
+ * Call on DOMContentLoaded and after HTMX swaps.
99
+ * @param {Document|Element} root - Root element to search within
100
+ */
101
+ export function initClickBurst(root = document) {
102
+ root.querySelectorAll('[data-click-burst]').forEach((el) => {
103
+ if (el.__clickBurstAttached) return;
104
+ el.__clickBurstAttached = true;
105
+
106
+ // Parse options from data attributes
107
+ const options = {};
108
+ if (el.dataset.clickBurstColor) options.color = el.dataset.clickBurstColor;
109
+ if (el.dataset.clickBurstOpacity) options.opacity = parseFloat(el.dataset.clickBurstOpacity);
110
+ if (el.dataset.clickBurstDuration) options.duration = parseInt(el.dataset.clickBurstDuration, 10);
111
+ if (el.dataset.clickBurstStartSize) options.startSize = parseInt(el.dataset.clickBurstStartSize, 10);
112
+ if (el.dataset.clickBurstEndSize) options.endSize = parseInt(el.dataset.clickBurstEndSize, 10);
113
+
114
+ attachClickBurst(el, options);
115
+ });
116
+ }
@@ -0,0 +1,457 @@
1
+ import Handlebars from 'handlebars';
2
+ import htmx from '../lib/htmx.js';
3
+ import { GrasprToast } from './toast.js';
4
+ import {
5
+ openGlobalModal,
6
+ closeGlobalModal,
7
+ getGlobalModalHeader,
8
+ getGlobalModalCloseButton,
9
+ setOnModalCloseWithConfirm,
10
+ } from './modal.js';
11
+
12
+ // ---------------------------
13
+ // Confirm Dialog
14
+ // ---------------------------
15
+
16
+ let confirmState = null;
17
+
18
+ function renderConfirmHtml(data) {
19
+ const tpl = document.getElementById('global-confirm-template');
20
+ if (!(tpl instanceof HTMLTemplateElement)) return null;
21
+ const render = Handlebars.compile(tpl.innerHTML);
22
+ return render(data || {});
23
+ }
24
+
25
+ function setConfirmMode(enabled) {
26
+ const header = getGlobalModalHeader();
27
+ const closeBtn = getGlobalModalCloseButton();
28
+ const dialog = document.querySelector('#global-modal [role="dialog"]');
29
+ if (enabled) {
30
+ // Entering confirm mode
31
+ if (header) header.classList.add('hidden');
32
+ if (closeBtn) closeBtn.classList.add('hidden');
33
+ if (dialog) {
34
+ dialog.classList.add('confirm-mode');
35
+ dialog.querySelectorAll('.card-detail-nav').forEach(btn => {
36
+ btn.style.display = 'none';
37
+ });
38
+ }
39
+ } else {
40
+ // Exiting confirm mode — restore header/close/chevrons immediately,
41
+ // but leave confirm-mode class for modal.js close transition to clean up
42
+ if (header) header.classList.remove('hidden');
43
+ if (closeBtn) closeBtn.classList.remove('hidden');
44
+ if (dialog) {
45
+ dialog.querySelectorAll('.card-detail-nav').forEach(btn => {
46
+ btn.style.display = '';
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ function finalizeConfirm(result) {
53
+ if (!confirmState) return;
54
+ const { resolve } = confirmState;
55
+ confirmState = null;
56
+ // Don't call setConfirmMode(false) — confirm-mode class is cleaned up
57
+ // by modal.js after the close transition to avoid a flash of unstyled modal.
58
+ setOnModalCloseWithConfirm(null);
59
+ resolve(result);
60
+ }
61
+
62
+ async function runConfirmAction() {
63
+ if (!confirmState) return;
64
+
65
+ const okBtn = document.querySelector('#global-modal [data-confirm-ok]');
66
+ const cancelBtn = document.querySelector('#global-modal [data-confirm-cancel]');
67
+ if (okBtn instanceof HTMLButtonElement) okBtn.disabled = true;
68
+ if (cancelBtn instanceof HTMLButtonElement) cancelBtn.disabled = true;
69
+
70
+ try {
71
+ if (typeof confirmState.onConfirm === 'function') {
72
+ await confirmState.onConfirm();
73
+ }
74
+ finalizeConfirm(true);
75
+ closeGlobalModal();
76
+ } catch (err) {
77
+ // Keep the modal open if the confirm action fails.
78
+ console.error('Confirm action failed:', err);
79
+ if (okBtn instanceof HTMLButtonElement) okBtn.disabled = false;
80
+ if (cancelBtn instanceof HTMLButtonElement) cancelBtn.disabled = false;
81
+ }
82
+ }
83
+
84
+ export const GrasprConfirm = {
85
+ /**
86
+ * Open a confirm dialog.
87
+ * Returns a Promise<boolean> (true = confirmed, false = canceled).
88
+ */
89
+ open({ message, subtext = '', cancelText = 'Cancel', confirmText = 'Confirm', checkboxLabel = '', onConfirm } = {}) {
90
+ const content = document.getElementById('global-modal-content');
91
+ const title = document.getElementById('global-modal-title');
92
+ if (!content) return Promise.resolve(false);
93
+
94
+ // Replace modal content with confirm UI
95
+ if (title) title.textContent = '';
96
+ setConfirmMode(true);
97
+
98
+ const html = renderConfirmHtml({ message, subtext, cancelText, confirmText, checkboxLabel });
99
+ if (html) content.innerHTML = html;
100
+
101
+ openGlobalModal();
102
+
103
+ // Focus confirm by default
104
+ const okBtn = content.querySelector('[data-confirm-ok]');
105
+ if (okBtn instanceof HTMLElement) queueMicrotask(() => okBtn.focus());
106
+
107
+ return new Promise((resolve) => {
108
+ confirmState = { resolve, onConfirm };
109
+ // Register callback so modal.js can cancel the confirm when modal is closed
110
+ setOnModalCloseWithConfirm(() => finalizeConfirm(false));
111
+ });
112
+ },
113
+ };
114
+
115
+ // ---------------------------
116
+ // Helper Functions
117
+ // ---------------------------
118
+
119
+ function notifyAfterAction({ refreshTarget, trigger, payload, eventName }) {
120
+ if (refreshTarget) {
121
+ const el = document.querySelector(refreshTarget);
122
+ if (el && typeof htmx?.trigger === 'function') {
123
+ htmx.trigger(el, 'refresh');
124
+ }
125
+ }
126
+ if (eventName) {
127
+ const customEvent = new CustomEvent(eventName, { detail: { trigger, payload }, bubbles: true });
128
+ document.body.dispatchEvent(customEvent);
129
+ }
130
+ }
131
+
132
+ function parseDatasetValue(v) {
133
+ if (v === '') return true;
134
+ if (v === 'true') return true;
135
+ if (v === 'false') return false;
136
+ if (/^-?\d+$/.test(v)) return Number.parseInt(v, 10);
137
+ return v;
138
+ }
139
+
140
+ function getConfirmPayload(trigger) {
141
+ const payload = {};
142
+ for (const [k, v] of Object.entries(trigger.dataset || {})) {
143
+ // Strip confirm-specific data-* keys; everything else becomes payload.
144
+ if (k.startsWith('confirm')) continue;
145
+ payload[k] = parseDatasetValue(v);
146
+ }
147
+
148
+ return payload;
149
+ }
150
+
151
+ async function runConfirmRequest({ method, url, payload, refreshTarget, trigger, eventName, spinner }) {
152
+ const m = String(method || 'POST').toUpperCase();
153
+ const u = String(url || '').trim();
154
+ if (!u) return;
155
+
156
+ if (spinner) {
157
+ const content = document.getElementById('global-modal-content');
158
+ if (content) content.innerHTML = renderSpinnerUI(spinner);
159
+ // Prevent modal close while request is in flight
160
+ setOnModalCloseWithConfirm(null);
161
+ }
162
+
163
+ try {
164
+ const res = await fetch(u, {
165
+ method: m,
166
+ headers: {
167
+ Accept: 'application/json',
168
+ 'Content-Type': 'application/json',
169
+ },
170
+ body: JSON.stringify(payload ?? {}),
171
+ });
172
+
173
+ // Try to show a toast based on JSON response (if any).
174
+ let data = null;
175
+ try {
176
+ data = await res.json();
177
+ } catch {
178
+ // ignore
179
+ }
180
+
181
+ if (res.ok) {
182
+ if (data && typeof data === 'object' && ('message' in data || 'status' in data)) {
183
+ GrasprToast?.show?.({
184
+ message: String(data.message ?? 'Done.'),
185
+ status: String(data.status ?? 'success'),
186
+ });
187
+ } else {
188
+ GrasprToast?.show?.({ message: 'Done.', status: 'success' });
189
+ }
190
+ } else {
191
+ const msg = data?.error || data?.message || `Request failed (${res.status})`;
192
+ GrasprToast?.show?.({ message: String(msg), status: 'error' });
193
+ throw new Error(String(msg));
194
+ }
195
+ } finally {
196
+ notifyAfterAction({ refreshTarget, trigger, payload, eventName });
197
+ }
198
+ }
199
+
200
+ // ---------------------------
201
+ // Spinner Mode Helper
202
+ // ---------------------------
203
+
204
+ function renderSpinnerUI(message) {
205
+ return `
206
+ <div class="flex flex-col items-center gap-3 py-4">
207
+ <svg class="animate-spin h-8 w-8 text-slate-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
208
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
209
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
210
+ </svg>
211
+ <p class="text-sm text-slate-600">${message}</p>
212
+ </div>
213
+ `;
214
+ }
215
+
216
+ // ---------------------------
217
+ // Progress Mode Helpers
218
+ // ---------------------------
219
+
220
+ async function fetchPendingCount(progressUrl) {
221
+ try {
222
+ const res = await fetch(progressUrl, {
223
+ headers: { Accept: 'application/json' },
224
+ });
225
+ if (!res.ok) return null;
226
+ const data = await res.json();
227
+ const count = data?.meta?.count ?? null;
228
+ if (count === null) return null;
229
+ return { count, total: data?.meta?.total ?? null };
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function renderProgressUI(total, { progressLabel = 'Processing...', progressItemLabel = 'processed' } = {}) {
236
+ return `
237
+ <div class="space-y-4 py-2" data-progress-container>
238
+ <p class="text-sm text-slate-700 font-medium">${progressLabel}</p>
239
+ <div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
240
+ <div class="confirm-progress-bar bg-slate-700 h-3 rounded-full" style="width: 0%"></div>
241
+ </div>
242
+ <p class="text-sm text-slate-500" data-progress-text>0 of ${total} ${progressItemLabel}</p>
243
+ </div>
244
+ `;
245
+ }
246
+
247
+ function updateProgressUI(done, total, progressItemLabel = 'processed') {
248
+ const bar = document.querySelector('#global-modal .confirm-progress-bar');
249
+ const text = document.querySelector('#global-modal [data-progress-text]');
250
+ const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
251
+ if (bar) bar.style.width = `${pct}%`;
252
+ if (text) text.textContent = `${done} of ${total} ${progressItemLabel}`;
253
+ }
254
+
255
+ async function runProgressLoop({
256
+ method, url, payload, refreshTarget, trigger, eventName, total,
257
+ progressLabel, progressItemLabel,
258
+ }) {
259
+ const content = document.getElementById('global-modal-content');
260
+ if (content) content.innerHTML = renderProgressUI(total, { progressLabel, progressItemLabel });
261
+
262
+ // Prevent modal close during progress
263
+ setOnModalCloseWithConfirm(null);
264
+
265
+ let done = 0;
266
+
267
+ while (done < total) {
268
+ let generated = 0;
269
+ try {
270
+ const res = await fetch(url, {
271
+ method: String(method || 'POST').toUpperCase(),
272
+ headers: {
273
+ Accept: 'application/json',
274
+ 'Content-Type': 'application/json',
275
+ },
276
+ body: JSON.stringify(payload),
277
+ });
278
+ if (res.ok) {
279
+ const data = await res.json();
280
+ generated = data?.meta?.count ?? 0;
281
+ } else {
282
+ console.warn(`Generate images request failed (${res.status})`);
283
+ break;
284
+ }
285
+ } catch (err) {
286
+ console.warn('Generate images network error:', err);
287
+ break;
288
+ }
289
+
290
+ if (generated === 0) break;
291
+ done += generated;
292
+ updateProgressUI(done, total, progressItemLabel);
293
+ }
294
+
295
+ // Brief pause before closing
296
+ await new Promise((r) => setTimeout(r, 500));
297
+
298
+ // Show toast
299
+ if (done === total) {
300
+ GrasprToast?.show?.({ message: `${done} of ${total} ${progressItemLabel}.`, status: 'success' });
301
+ } else if (done > 0) {
302
+ GrasprToast?.show?.({ message: `${done} of ${total} ${progressItemLabel}.`, status: 'warning' });
303
+ } else {
304
+ GrasprToast?.show?.({ message: `Nothing ${progressItemLabel}.`, status: 'error' });
305
+ }
306
+
307
+ notifyAfterAction({ refreshTarget, trigger, payload, eventName });
308
+ }
309
+
310
+ // ---------------------------
311
+ // Event Listeners
312
+ // ---------------------------
313
+
314
+ // Delegated HTML trigger:
315
+ // <button
316
+ // data-confirm
317
+ // data-confirm-message="Retire this series?"
318
+ // data-confirm-subtext="This cannot be undone."
319
+ // data-confirm-confirm-text="Confirm"
320
+ // data-confirm-cancel-text="Cancel"
321
+ // data-confirm-event="series:retire-confirmed"
322
+ // data-confirm-progress-url="/api/admin/generate-images/count?type=series"
323
+ // >
324
+ document.addEventListener('click', async (e) => {
325
+ const trigger = e.target.closest('[data-confirm]');
326
+ if (!(trigger instanceof HTMLElement)) return;
327
+
328
+ let message = trigger.getAttribute('data-confirm-message') || '';
329
+ const subtext = trigger.getAttribute('data-confirm-subtext') || '';
330
+ const confirmText = trigger.getAttribute('data-confirm-confirm-text') || 'Confirm';
331
+ const cancelText = trigger.getAttribute('data-confirm-cancel-text') || 'Cancel';
332
+ const eventName = trigger.getAttribute('data-confirm-event') || '';
333
+ const payload = getConfirmPayload(trigger);
334
+ const requestMethod = trigger.getAttribute('data-confirm-request-method') || '';
335
+ const requestUrl = trigger.getAttribute('data-confirm-request-url') || '';
336
+ const refreshTarget = trigger.getAttribute('data-confirm-refresh-target') || '';
337
+ const progressUrl = trigger.getAttribute('data-confirm-progress-url') || '';
338
+ const checkboxLabel = trigger.getAttribute('data-confirm-checkbox-label') || '';
339
+ const checkboxKey = trigger.getAttribute('data-confirm-checkbox-key') || '';
340
+ const spinner = trigger.getAttribute('data-confirm-spinner') || '';
341
+ const progressLabel = trigger.getAttribute('data-confirm-progress-label') || 'Processing...';
342
+ const progressItemLabel = trigger.getAttribute('data-confirm-progress-item-label') || 'processed';
343
+
344
+ let progressTotal = 0;
345
+ let preCheckForce = false;
346
+
347
+ // Phase 1: Pre-fetch count for progress mode
348
+ if (progressUrl) {
349
+ trigger.disabled = true;
350
+ const result = await fetchPendingCount(progressUrl);
351
+ trigger.disabled = false;
352
+
353
+ if (result === null) {
354
+ GrasprToast?.show?.({ message: 'Could not fetch count.', status: 'error' });
355
+ return;
356
+ }
357
+
358
+ const { count, total } = result;
359
+
360
+ if (count === 0) {
361
+ // Nothing missing — but if a force checkbox is offered and items exist,
362
+ // show the dialog with the checkbox pre-checked so the user can regenerate.
363
+ if (checkboxLabel && total > 0) {
364
+ progressTotal = total;
365
+ preCheckForce = true;
366
+ } else {
367
+ GrasprToast?.show?.({ message: 'Nothing to generate — all images already exist.', status: 'info' });
368
+ return;
369
+ }
370
+ } else {
371
+ progressTotal = count;
372
+ }
373
+
374
+ message = message.replace('{count}', String(progressTotal));
375
+ }
376
+
377
+ GrasprConfirm.open({
378
+ message,
379
+ subtext,
380
+ confirmText,
381
+ cancelText,
382
+ checkboxLabel,
383
+ onConfirm: async () => {
384
+ // Read actual checkbox state — preCheckForce only affects the visual default
385
+ const checkbox = document.querySelector('#global-modal [data-confirm-checkbox]');
386
+ const checked = checkbox instanceof HTMLInputElement && checkbox.checked;
387
+ if (checked && checkboxKey) {
388
+ payload[checkboxKey] = true;
389
+ }
390
+
391
+ // If all images existed (preCheckForce) but user unchecked — nothing to do
392
+ if (preCheckForce && !checked) {
393
+ GrasprToast?.show?.({ message: 'Nothing to generate — all images already exist.', status: 'info' });
394
+ return;
395
+ }
396
+
397
+ if (progressTotal > 0 && requestMethod && requestUrl) {
398
+ let total = progressTotal;
399
+
400
+ // If force was manually checked, re-fetch count including records with images
401
+ // (when preCheckForce, progressTotal is already the full count)
402
+ if (checked && !preCheckForce && progressUrl) {
403
+ const sep = progressUrl.includes('?') ? '&' : '?';
404
+ const forceResult = await fetchPendingCount(progressUrl + sep + 'force=1');
405
+ if (forceResult !== null && forceResult.count > 0) {
406
+ total = forceResult.count;
407
+ }
408
+ }
409
+
410
+ await runProgressLoop({
411
+ method: requestMethod,
412
+ url: requestUrl,
413
+ payload,
414
+ refreshTarget,
415
+ trigger,
416
+ eventName,
417
+ total,
418
+ progressLabel,
419
+ progressItemLabel,
420
+ });
421
+ } else if (requestMethod && requestUrl) {
422
+ await runConfirmRequest({
423
+ method: requestMethod,
424
+ url: requestUrl,
425
+ payload,
426
+ refreshTarget,
427
+ trigger,
428
+ eventName,
429
+ spinner,
430
+ });
431
+ } else if (eventName) {
432
+ notifyAfterAction({ trigger, payload, eventName });
433
+ }
434
+ },
435
+ });
436
+
437
+ // Visually pre-check the force checkbox when all images already exist
438
+ if (preCheckForce) {
439
+ const checkbox = document.querySelector('#global-modal [data-confirm-checkbox]');
440
+ if (checkbox instanceof HTMLInputElement) checkbox.checked = true;
441
+ }
442
+
443
+ });
444
+
445
+ // Confirm buttons
446
+ document.addEventListener('click', (e) => {
447
+ if (!confirmState) {
448
+ return;
449
+ }
450
+ if (e.target.closest('[data-confirm-cancel]')) {
451
+ closeGlobalModal();
452
+ return;
453
+ }
454
+ if (e.target.closest('[data-confirm-ok]')) {
455
+ runConfirmAction();
456
+ }
457
+ });
@@ -0,0 +1,21 @@
1
+ import { openGlobalModal } from './modal.js';
2
+
3
+ document.addEventListener('click', (e) => {
4
+ const trigger = e.target.closest('[data-image-preview]');
5
+ if (!(trigger instanceof HTMLElement)) return;
6
+
7
+ e.preventDefault();
8
+
9
+ const src = trigger.getAttribute('data-image-src');
10
+ const alt = trigger.getAttribute('data-image-alt') || '';
11
+ if (!src) return;
12
+
13
+ const title = document.getElementById('global-modal-title');
14
+ const content = document.getElementById('global-modal-content');
15
+ if (!content) return;
16
+
17
+ if (title) title.textContent = alt;
18
+ content.innerHTML = `<div class="flex justify-center"><img src="${src}" alt="${alt}" class="max-w-full max-h-[70dvh] rounded" /></div>`;
19
+
20
+ openGlobalModal();
21
+ });
@@ -0,0 +1,37 @@
1
+ export {
2
+ GrasprToast,
3
+ registerToastHelpers,
4
+ initToastEventHandlers,
5
+ openToast,
6
+ closeToast,
7
+ } from './toast.js';
8
+
9
+ export {
10
+ setOnModalCloseWithConfirm,
11
+ getGlobalModal,
12
+ getGlobalModalDialog,
13
+ getGlobalModalHeader,
14
+ getGlobalModalCloseButton,
15
+ isGlobalModalOpen,
16
+ openGlobalModal,
17
+ closeGlobalModal,
18
+ } from './modal.js';
19
+
20
+ export { openFormModal } from './modal-form.js';
21
+
22
+ export { GrasprConfirm } from './confirm-dialog.js';
23
+
24
+ export {
25
+ createBurst,
26
+ attachClickBurst,
27
+ initClickBurst,
28
+ } from './click-burst.js';
29
+
30
+ export { createTypeahead } from './typeahead.js';
31
+
32
+ // Side-effect modules (register event listeners / globals)
33
+ import './modal-form.js';
34
+ import './confirm-dialog.js';
35
+ import './image-preview.js';
36
+ import './typeahead.js';
37
+