@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.
- package/README.md +119 -0
- package/package.json +19 -0
- package/src/auth.js +47 -0
- package/src/core/auth-state.js +227 -0
- package/src/core/boosted-nav.js +94 -0
- package/src/core/csrf.js +63 -0
- package/src/core/forms.js +290 -0
- package/src/core/navigation.js +115 -0
- package/src/core/pagination.js +94 -0
- package/src/core/search.js +77 -0
- package/src/core/sortable.js +116 -0
- package/src/core/table-sort.js +111 -0
- package/src/fetch-client.js +46 -0
- package/src/helpers/debounce.js +48 -0
- package/src/helpers/escape-html.js +10 -0
- package/src/helpers/handlebars-helpers.js +171 -0
- package/src/helpers/index.js +6 -0
- package/src/helpers/populate-select.js +21 -0
- package/src/helpers/route-params.js +50 -0
- package/src/helpers/utils.js +27 -0
- package/src/index.js +73 -0
- package/src/init.js +13 -0
- package/src/lib/client-side-templates.js +75 -0
- package/src/lib/htmx.js +7 -0
- package/src/lib/json-enc.js +20 -0
- package/src/ui/click-burst.js +116 -0
- package/src/ui/confirm-dialog.js +457 -0
- package/src/ui/image-preview.js +21 -0
- package/src/ui/index.js +37 -0
- package/src/ui/modal-form.js +162 -0
- package/src/ui/modal.js +127 -0
- package/src/ui/toast.js +126 -0
- package/src/ui/typeahead.js +186 -0
- package/styles/base.css +165 -0
|
@@ -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
|
+
});
|
package/src/ui/index.js
ADDED
|
@@ -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
|
+
|