@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,290 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Form error handling + modal form lifecycle
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Manages inline form errors, error response interception, form submission
|
|
5
|
+
// tracking, modal auto-open on content swap, and modal close + refresh on success.
|
|
6
|
+
// Fully self-registering — import for side effects only.
|
|
7
|
+
|
|
8
|
+
import htmx from '../lib/htmx.js';
|
|
9
|
+
import { GrasprToast } from '../ui/toast.js';
|
|
10
|
+
import {
|
|
11
|
+
openGlobalModal,
|
|
12
|
+
closeGlobalModal,
|
|
13
|
+
isGlobalModalOpen,
|
|
14
|
+
} from '../ui/index.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------
|
|
17
|
+
// Form inline error handling
|
|
18
|
+
// ---------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Show inline errors on form fields.
|
|
22
|
+
* @param {HTMLFormElement} form - The form element
|
|
23
|
+
* @param {Object} errors - Object mapping field names to error messages
|
|
24
|
+
* @param {string} generalMessage - General error message for the form
|
|
25
|
+
*/
|
|
26
|
+
function showFormErrors(form, errors, generalMessage) {
|
|
27
|
+
if (!(form instanceof HTMLFormElement)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Show field-level errors
|
|
32
|
+
for (const [fieldName, errorMessage] of Object.entries(errors || {})) {
|
|
33
|
+
const input = form.querySelector(`[name="${fieldName}"]`);
|
|
34
|
+
if (!input) continue;
|
|
35
|
+
|
|
36
|
+
// Add error class to input
|
|
37
|
+
input.classList.add('field-error');
|
|
38
|
+
|
|
39
|
+
// Find or create error text element
|
|
40
|
+
let errorText = input.parentElement?.querySelector('.field-error-text');
|
|
41
|
+
if (!errorText) {
|
|
42
|
+
errorText = document.createElement('div');
|
|
43
|
+
errorText.className = 'field-error-text hidden';
|
|
44
|
+
input.parentElement?.appendChild(errorText);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
errorText.textContent = errorMessage;
|
|
48
|
+
errorText.classList.remove('hidden');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Show general error message at top of form
|
|
52
|
+
if (generalMessage) {
|
|
53
|
+
let errorBanner = form.querySelector('.form-error-banner');
|
|
54
|
+
if (!errorBanner) {
|
|
55
|
+
errorBanner = document.createElement('div');
|
|
56
|
+
errorBanner.className = 'form-error-banner';
|
|
57
|
+
form.insertBefore(errorBanner, form.firstChild);
|
|
58
|
+
}
|
|
59
|
+
errorBanner.textContent = generalMessage;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear all inline errors from a form.
|
|
65
|
+
* @param {HTMLFormElement} form - The form element
|
|
66
|
+
*/
|
|
67
|
+
function clearFormErrors(form) {
|
|
68
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
69
|
+
|
|
70
|
+
// Remove error classes from inputs
|
|
71
|
+
form.querySelectorAll('.field-error').forEach((input) => {
|
|
72
|
+
input.classList.remove('field-error');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Hide error text elements
|
|
76
|
+
form.querySelectorAll('.field-error-text').forEach((errorText) => {
|
|
77
|
+
errorText.classList.add('hidden');
|
|
78
|
+
errorText.textContent = '';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Remove error banner
|
|
82
|
+
form.querySelectorAll('.form-error-banner').forEach((banner) => {
|
|
83
|
+
banner.remove();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Clear error state for a specific field.
|
|
89
|
+
* @param {HTMLElement} input - The input element
|
|
90
|
+
*/
|
|
91
|
+
function clearFieldError(input) {
|
|
92
|
+
if (!(input instanceof HTMLElement)) return;
|
|
93
|
+
|
|
94
|
+
input.classList.remove('field-error');
|
|
95
|
+
|
|
96
|
+
const errorText = input.parentElement?.querySelector('.field-error-text');
|
|
97
|
+
if (errorText) {
|
|
98
|
+
errorText.classList.add('hidden');
|
|
99
|
+
errorText.textContent = '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Clear field errors when user interacts with the field
|
|
104
|
+
document.addEventListener('focus', (e) => {
|
|
105
|
+
const input = e.target;
|
|
106
|
+
if (input instanceof HTMLElement && input.classList.contains('field-error')) {
|
|
107
|
+
clearFieldError(input);
|
|
108
|
+
}
|
|
109
|
+
}, true);
|
|
110
|
+
|
|
111
|
+
// ---------------------------
|
|
112
|
+
// Form submission tracking
|
|
113
|
+
// ---------------------------
|
|
114
|
+
|
|
115
|
+
// Track the currently submitting form for error handling
|
|
116
|
+
let currentSubmittingForm = null;
|
|
117
|
+
|
|
118
|
+
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
|
119
|
+
const elt = e.detail?.elt;
|
|
120
|
+
if (elt instanceof HTMLFormElement) {
|
|
121
|
+
currentSubmittingForm = elt;
|
|
122
|
+
} else if (elt instanceof Element) {
|
|
123
|
+
currentSubmittingForm = elt.closest('form');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------
|
|
128
|
+
// Error response interception
|
|
129
|
+
// ---------------------------
|
|
130
|
+
|
|
131
|
+
// Intercept HTMX responses BEFORE swap to handle form errors (inline or toast)
|
|
132
|
+
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
|
133
|
+
const detail = e.detail || {};
|
|
134
|
+
const xhr = detail.xhr;
|
|
135
|
+
const elt = detail.elt;
|
|
136
|
+
|
|
137
|
+
// Try to parse JSON response
|
|
138
|
+
let responseData = null;
|
|
139
|
+
try {
|
|
140
|
+
const responseText = xhr?.responseText;
|
|
141
|
+
if (responseText) {
|
|
142
|
+
responseData = JSON.parse(responseText);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Not JSON or parsing failed
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if this is an error response
|
|
150
|
+
const status = responseData?.status;
|
|
151
|
+
const message = responseData?.message;
|
|
152
|
+
|
|
153
|
+
if (status !== 'error' || !message) {
|
|
154
|
+
return; // Not an error response, let HTMX handle normally
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Find the form that triggered this request
|
|
158
|
+
// Use the tracked form first, then try to detect from the element
|
|
159
|
+
let form = currentSubmittingForm;
|
|
160
|
+
|
|
161
|
+
if (!form && elt instanceof HTMLFormElement) {
|
|
162
|
+
form = elt;
|
|
163
|
+
} else if (!form && elt instanceof Element) {
|
|
164
|
+
form = elt.closest('form');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// If elt is not inside a form (e.g., it's the toast target), look for forms in the modal
|
|
168
|
+
if (!form && isGlobalModalOpen()) {
|
|
169
|
+
form = document.querySelector('#global-modal-content form[data-form-errors="inline"]');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const useInlineErrors = form?.getAttribute('data-form-errors') === 'inline';
|
|
173
|
+
const hasFieldErrors = responseData.errors && typeof responseData.errors === 'object' && Object.keys(responseData.errors).length > 0;
|
|
174
|
+
|
|
175
|
+
// Handle error responses
|
|
176
|
+
if (useInlineErrors && hasFieldErrors) {
|
|
177
|
+
// Show inline errors on the form - prevent the swap to toast
|
|
178
|
+
detail.shouldSwap = false;
|
|
179
|
+
clearFormErrors(form);
|
|
180
|
+
showFormErrors(form, responseData.errors, message);
|
|
181
|
+
} else if (useInlineErrors && !hasFieldErrors) {
|
|
182
|
+
// Invariant/form-level errors on inline-error forms: show in form, not toast
|
|
183
|
+
detail.shouldSwap = false;
|
|
184
|
+
clearFormErrors(form);
|
|
185
|
+
showFormErrors(form, {}, message);
|
|
186
|
+
} else {
|
|
187
|
+
// Forms without inline errors: show toast
|
|
188
|
+
// Allow the swap if targeting toast, otherwise show manually
|
|
189
|
+
if (detail.target && detail.target.id === 'global-toast-content') {
|
|
190
|
+
detail.shouldSwap = true; // Allow HTMX to swap the error response into toast
|
|
191
|
+
} else {
|
|
192
|
+
detail.shouldSwap = false;
|
|
193
|
+
GrasprToast?.show?.({ message, status: 'error' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------
|
|
199
|
+
// Modal auto-open + close/refresh on success
|
|
200
|
+
// ---------------------------
|
|
201
|
+
|
|
202
|
+
// If an HTMX request swaps content into the modal container, auto-open it.
|
|
203
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
204
|
+
const target = e.detail?.target;
|
|
205
|
+
if (target && target.id === 'global-modal-content') {
|
|
206
|
+
openGlobalModal();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// If an HTMX request is triggered from inside the global modal and succeeds,
|
|
211
|
+
// automatically dismiss the modal.
|
|
212
|
+
//
|
|
213
|
+
// Example: POST /api/series from the "Create series" modal should close the modal on success.
|
|
214
|
+
document.body.addEventListener('htmx:afterRequest', (e) => {
|
|
215
|
+
// Clear the tracked submitting form
|
|
216
|
+
currentSubmittingForm = null;
|
|
217
|
+
|
|
218
|
+
const detail = e.detail || {};
|
|
219
|
+
const xhr = detail.xhr;
|
|
220
|
+
const status = typeof xhr?.status === 'number' ? xhr.status : null;
|
|
221
|
+
const ok = status !== null ? status >= 200 && status < 300 : !!detail.successful;
|
|
222
|
+
if (!ok) return;
|
|
223
|
+
if (!isGlobalModalOpen()) return;
|
|
224
|
+
|
|
225
|
+
const elt = detail.elt;
|
|
226
|
+
if (!(elt instanceof Element)) return;
|
|
227
|
+
|
|
228
|
+
// Only close if the request originated inside the modal (not just targeting it).
|
|
229
|
+
if (elt.closest('#global-modal')) {
|
|
230
|
+
// Check for success redirect - first from response meta, then from data attribute
|
|
231
|
+
const form = elt.closest('form');
|
|
232
|
+
let successRedirect = form?.getAttribute('data-success-redirect');
|
|
233
|
+
|
|
234
|
+
// Try to get redirect from response meta
|
|
235
|
+
try {
|
|
236
|
+
const responseData = JSON.parse(xhr?.responseText || '{}');
|
|
237
|
+
if (responseData.meta?.redirect) {
|
|
238
|
+
successRedirect = responseData.meta.redirect;
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// Ignore parse errors
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (successRedirect) {
|
|
245
|
+
// If already on a real page (not landing/login), reload in place —
|
|
246
|
+
// auth-state.js handles permission checks after the page loads.
|
|
247
|
+
const currentPath = window.location.pathname;
|
|
248
|
+
if (currentPath !== '/' && !currentPath.startsWith('/login') && !currentPath.startsWith('/signup')) {
|
|
249
|
+
window.location.reload();
|
|
250
|
+
} else {
|
|
251
|
+
window.location.href = successRedirect;
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
closeGlobalModal();
|
|
257
|
+
|
|
258
|
+
// Clear any form errors on successful submit
|
|
259
|
+
if (form instanceof HTMLFormElement) {
|
|
260
|
+
clearFormErrors(form);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Optional: refresh something after a successful modal submit.
|
|
264
|
+
// Two patterns supported:
|
|
265
|
+
//
|
|
266
|
+
// 1. data-refresh-target="#some-element" — fires HTMX "refresh" trigger
|
|
267
|
+
// on the element (used by list pages with hx-trigger="... refresh").
|
|
268
|
+
//
|
|
269
|
+
// 2. data-refresh-event="event-name" — dispatches a custom event on
|
|
270
|
+
// document.body (used by detail pages where multiple elements listen
|
|
271
|
+
// via hx-trigger="... event-name from:body").
|
|
272
|
+
//
|
|
273
|
+
// Both are read from the form first, then from the triggering element.
|
|
274
|
+
|
|
275
|
+
const refreshSelector = form?.getAttribute('data-refresh-target')
|
|
276
|
+
|| elt.getAttribute('data-refresh-target')
|
|
277
|
+
|| elt.getAttribute('data-refresh-click');
|
|
278
|
+
if (refreshSelector) {
|
|
279
|
+
const refreshEl = document.querySelector(refreshSelector);
|
|
280
|
+
if (refreshEl instanceof HTMLElement && typeof htmx?.trigger === 'function') {
|
|
281
|
+
htmx.trigger(refreshEl, 'refresh');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const refreshEvent = form?.getAttribute('data-refresh-event') || elt.getAttribute('data-refresh-event');
|
|
286
|
+
if (refreshEvent) {
|
|
287
|
+
document.body.dispatchEvent(new CustomEvent(refreshEvent, { bubbles: true }));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Navigation utilities + active nav highlighting
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// URL helpers, active nav link highlighting, and history event handlers.
|
|
5
|
+
// Self-registers afterSwap/afterSettle/pushedIntoHistory/popstate listeners.
|
|
6
|
+
|
|
7
|
+
export function normalizePath(p) {
|
|
8
|
+
if (!p) return '/';
|
|
9
|
+
if (p === '/') return '/';
|
|
10
|
+
return p.endsWith('/') ? p : `${p}/`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setActiveNav(pathOverride) {
|
|
14
|
+
const current = normalizePath(pathOverride ?? window.location.pathname);
|
|
15
|
+
|
|
16
|
+
// Subnav link highlighting (exact match, or prefix match via data-nav-match)
|
|
17
|
+
document.querySelectorAll('a[data-nav]').forEach((a) => {
|
|
18
|
+
const href = normalizePath(a.getAttribute('href'));
|
|
19
|
+
const matchPrefix = a.getAttribute('data-nav-match');
|
|
20
|
+
const isActive = href === current || (matchPrefix && current.startsWith(matchPrefix));
|
|
21
|
+
|
|
22
|
+
a.classList.toggle('active-nav', isActive);
|
|
23
|
+
a.classList.toggle('underline', isActive);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Section tab highlighting (prefix match)
|
|
27
|
+
document.querySelectorAll('a[data-nav-section]').forEach((a) => {
|
|
28
|
+
const prefix = a.getAttribute('data-nav-section');
|
|
29
|
+
const isActive = current.startsWith(prefix);
|
|
30
|
+
|
|
31
|
+
a.classList.toggle('active-nav', isActive);
|
|
32
|
+
a.classList.toggle('underline', isActive);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Subnav toggling — show the matching section, hide others
|
|
36
|
+
document.querySelectorAll('[data-subnav]').forEach((el) => {
|
|
37
|
+
const prefix = el.getAttribute('data-subnav');
|
|
38
|
+
const show = current.startsWith(prefix);
|
|
39
|
+
el.style.display = show ? '' : 'none';
|
|
40
|
+
el.classList.remove('hidden');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setUrlParam(key, value) {
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(window.location.href);
|
|
47
|
+
if (value === null || value === undefined || value === '') {
|
|
48
|
+
url.searchParams.delete(key);
|
|
49
|
+
} else {
|
|
50
|
+
url.searchParams.set(key, String(value));
|
|
51
|
+
}
|
|
52
|
+
history.pushState({}, '', url.pathname + url.search + url.hash);
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getUrlParam(key) {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(window.location.href).searchParams.get(key);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mergeHxVals(el, vals) {
|
|
67
|
+
let existing = {};
|
|
68
|
+
try {
|
|
69
|
+
existing = JSON.parse(el.getAttribute('hx-vals') || '{}');
|
|
70
|
+
} catch { /* ignore */ }
|
|
71
|
+
el.setAttribute('hx-vals', JSON.stringify({ ...existing, ...vals }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getUrlParamInt(key, fallback = 1) {
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(window.location.href);
|
|
77
|
+
const v = url.searchParams.get(key);
|
|
78
|
+
if (!v) return fallback;
|
|
79
|
+
const n = parseInt(v, 10);
|
|
80
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
81
|
+
} catch {
|
|
82
|
+
return fallback;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------
|
|
87
|
+
// Self-registered lifecycle handlers
|
|
88
|
+
// ---------------------------
|
|
89
|
+
|
|
90
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
91
|
+
setActiveNav();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
95
|
+
// Always update nav (nav elements are outside #app, so they persist across swaps)
|
|
96
|
+
setActiveNav();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Update nav highlighting after HTMX pushes a new URL to history.
|
|
100
|
+
// Use e.detail.path since window.location may not be updated yet.
|
|
101
|
+
document.body.addEventListener('htmx:pushedIntoHistory', (e) => {
|
|
102
|
+
const newPath = e.detail?.path;
|
|
103
|
+
setActiveNav(newPath);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Also try afterSettle as a fallback (with microtask delay to ensure URL is updated)
|
|
107
|
+
document.body.addEventListener('htmx:afterSettle', (e) => {
|
|
108
|
+
queueMicrotask(() => {
|
|
109
|
+
setActiveNav();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
window.addEventListener('popstate', () => {
|
|
114
|
+
setActiveNav();
|
|
115
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Pagination controls
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Wires up paginated HTMX sources, prev/next buttons, goto-page forms,
|
|
5
|
+
// and popstate re-sync. Self-registering — also exports initPagination()
|
|
6
|
+
// for use in DOMContentLoaded/afterSwap orchestration.
|
|
7
|
+
|
|
8
|
+
import htmx from '../lib/htmx.js';
|
|
9
|
+
import { setUrlParam, getUrlParamInt, mergeHxVals } from './navigation.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wire paginated HTMX sources to read the current page from the URL.
|
|
13
|
+
* @param {Document|HTMLElement} root - The root to search within
|
|
14
|
+
*/
|
|
15
|
+
export function initPagination(root = document) {
|
|
16
|
+
root.querySelectorAll('[data-pagination-param]').forEach((el) => {
|
|
17
|
+
if (!(el instanceof HTMLElement)) return;
|
|
18
|
+
const param = el.getAttribute('data-pagination-param') || 'page';
|
|
19
|
+
const page = getUrlParamInt(param, 1);
|
|
20
|
+
mergeHxVals(el, { [param]: page });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pagination controls: Prev/Next
|
|
25
|
+
// Buttons are rendered by the pagination component template and carry:
|
|
26
|
+
// - data-page="N"
|
|
27
|
+
// - data-pagination-source="#selector-for-hx-element"
|
|
28
|
+
// - data-pagination-param="page"
|
|
29
|
+
document.addEventListener('click', (e) => {
|
|
30
|
+
const btn = e.target.closest('[data-action="paginate-prev"], [data-action="paginate-next"]');
|
|
31
|
+
if (!(btn instanceof HTMLElement)) return;
|
|
32
|
+
|
|
33
|
+
const pageStr = btn.getAttribute('data-page') || '';
|
|
34
|
+
const page = parseInt(pageStr, 10);
|
|
35
|
+
if (!Number.isFinite(page) || page <= 0) return;
|
|
36
|
+
|
|
37
|
+
const sourceSel = btn.getAttribute('data-pagination-source') || '';
|
|
38
|
+
const param = btn.getAttribute('data-pagination-param') || 'page';
|
|
39
|
+
if (!sourceSel) return;
|
|
40
|
+
|
|
41
|
+
const sourceEl = document.querySelector(sourceSel);
|
|
42
|
+
if (!(sourceEl instanceof HTMLElement)) return;
|
|
43
|
+
|
|
44
|
+
mergeHxVals(sourceEl, { [param]: page });
|
|
45
|
+
|
|
46
|
+
// Update URL so pagination is shareable and back/forward works.
|
|
47
|
+
setUrlParam(param, page);
|
|
48
|
+
|
|
49
|
+
// Trigger the refresh request (and pagination will re-render from the response meta).
|
|
50
|
+
if (typeof htmx?.trigger === 'function') {
|
|
51
|
+
htmx.trigger(sourceEl, 'refresh');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Pagination controls: Go to page
|
|
56
|
+
document.addEventListener('submit', (e) => {
|
|
57
|
+
const form = e.target.closest('[data-action="paginate-goto"]');
|
|
58
|
+
if (!form) return;
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
|
|
61
|
+
const input = form.querySelector('input[type="number"]');
|
|
62
|
+
if (!input) return;
|
|
63
|
+
|
|
64
|
+
const page = parseInt(input.value, 10);
|
|
65
|
+
const lastPage = parseInt(form.getAttribute('data-last-page') || '1', 10);
|
|
66
|
+
if (!Number.isFinite(page) || page < 1 || page > lastPage) return;
|
|
67
|
+
|
|
68
|
+
const sourceSel = form.getAttribute('data-pagination-source') || '';
|
|
69
|
+
const param = form.getAttribute('data-pagination-param') || 'page';
|
|
70
|
+
if (!sourceSel) return;
|
|
71
|
+
|
|
72
|
+
const sourceEl = document.querySelector(sourceSel);
|
|
73
|
+
if (!(sourceEl instanceof HTMLElement)) return;
|
|
74
|
+
|
|
75
|
+
mergeHxVals(sourceEl, { [param]: page });
|
|
76
|
+
setUrlParam(param, page);
|
|
77
|
+
|
|
78
|
+
if (typeof htmx?.trigger === 'function') {
|
|
79
|
+
htmx.trigger(sourceEl, 'refresh');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Re-sync paginated sources on back/forward navigation.
|
|
84
|
+
window.addEventListener('popstate', () => {
|
|
85
|
+
document.querySelectorAll('[data-pagination-param]').forEach((el) => {
|
|
86
|
+
if (!(el instanceof HTMLElement)) return;
|
|
87
|
+
const param = el.getAttribute('data-pagination-param') || 'page';
|
|
88
|
+
const page = getUrlParamInt(param, 1);
|
|
89
|
+
mergeHxVals(el, { [param]: page });
|
|
90
|
+
if (typeof htmx?.trigger === 'function') {
|
|
91
|
+
htmx.trigger(el, 'refresh');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Search input wiring (delegated)
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Debounced search: updates hx-get URL on target element and triggers refresh.
|
|
5
|
+
// Usage: <input data-search-target="#some-htmx-element" data-search-endpoint="/api/foo" />
|
|
6
|
+
// Fully self-registering — import for side effects only.
|
|
7
|
+
|
|
8
|
+
import htmx from '../lib/htmx.js';
|
|
9
|
+
import { debounce, scrubSearchInput } from '../helpers/index.js';
|
|
10
|
+
import { setUrlParam, mergeHxVals } from './navigation.js';
|
|
11
|
+
|
|
12
|
+
const searchDebouncers = new WeakMap();
|
|
13
|
+
|
|
14
|
+
document.addEventListener('input', (e) => {
|
|
15
|
+
const input = e.target;
|
|
16
|
+
if (!(input instanceof HTMLInputElement)) return;
|
|
17
|
+
if (!input.dataset.searchTarget) return;
|
|
18
|
+
|
|
19
|
+
const targetSelector = input.dataset.searchTarget;
|
|
20
|
+
const baseEndpoint = input.dataset.searchEndpoint || '/api/series';
|
|
21
|
+
|
|
22
|
+
// Get or create debouncer for this input
|
|
23
|
+
let debouncedSearch = searchDebouncers.get(input);
|
|
24
|
+
if (!debouncedSearch) {
|
|
25
|
+
debouncedSearch = debounce((inp, sel, endpoint) => {
|
|
26
|
+
const target = document.querySelector(sel);
|
|
27
|
+
if (!(target instanceof HTMLElement)) return;
|
|
28
|
+
|
|
29
|
+
const rawValue = inp.value.trim();
|
|
30
|
+
const scrubbed = scrubSearchInput(rawValue);
|
|
31
|
+
|
|
32
|
+
// Detect UUID pattern
|
|
33
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
34
|
+
const isUuid = uuidPattern.test(scrubbed);
|
|
35
|
+
|
|
36
|
+
let url = endpoint;
|
|
37
|
+
if (scrubbed) {
|
|
38
|
+
const paramKey = isUuid ? 'id' : 'search';
|
|
39
|
+
const params = new URLSearchParams({ [paramKey]: scrubbed });
|
|
40
|
+
url = `${endpoint}?${params.toString()}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Reset pagination to page 1 so search results aren't empty on page N
|
|
44
|
+
const pageParam = target.getAttribute('data-pagination-param') || 'page';
|
|
45
|
+
mergeHxVals(target, { [pageParam]: 1 });
|
|
46
|
+
setUrlParam(pageParam, null);
|
|
47
|
+
|
|
48
|
+
// Show loading state, then trigger refresh after min visible duration
|
|
49
|
+
target.classList.add('search-loading');
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
target.setAttribute('hx-get', url);
|
|
52
|
+
htmx.process(target);
|
|
53
|
+
|
|
54
|
+
if (typeof htmx?.trigger === 'function') {
|
|
55
|
+
htmx.trigger(target, 'refresh');
|
|
56
|
+
}
|
|
57
|
+
}, 100);
|
|
58
|
+
}, 350);
|
|
59
|
+
searchDebouncers.set(input, debouncedSearch);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
debouncedSearch(input, targetSelector, baseEndpoint);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Remove search loading state after swap completes
|
|
66
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
67
|
+
const target = e.detail?.target;
|
|
68
|
+
if (target instanceof HTMLElement) {
|
|
69
|
+
target.classList.remove('search-loading');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Re-init after HTMX history cache restoration (back/forward navigation)
|
|
74
|
+
document.body.addEventListener('htmx:historyRestore', () => {
|
|
75
|
+
// No-op — search inputs are stateless (delegated input handler re-attaches automatically).
|
|
76
|
+
// Kept for documentation: if search inputs ever need re-initialization, add it here.
|
|
77
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Drag-and-drop sorting (delegated)
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Initializes SortableJS on elements with [data-sortable] after HTMX swaps.
|
|
5
|
+
// Sends PATCH with redistributed sort_order values on drag end.
|
|
6
|
+
// Fully self-registering — import for side effects only.
|
|
7
|
+
|
|
8
|
+
import Sortable from 'sortablejs';
|
|
9
|
+
import htmx from '../lib/htmx.js';
|
|
10
|
+
import { GrasprToast } from '../ui/toast.js';
|
|
11
|
+
|
|
12
|
+
const instances = new WeakMap();
|
|
13
|
+
|
|
14
|
+
function isSearchActive(container) {
|
|
15
|
+
const hxGet = container.getAttribute('hx-get') || '';
|
|
16
|
+
return hxGet.includes('search=');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function initSortable(container) {
|
|
20
|
+
// Tear down previous instance
|
|
21
|
+
const prev = instances.get(container);
|
|
22
|
+
if (prev) {
|
|
23
|
+
prev.destroy();
|
|
24
|
+
instances.delete(container);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Disable when search is active
|
|
28
|
+
if (isSearchActive(container)) {
|
|
29
|
+
container.classList.add('sortable-disabled');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
container.classList.remove('sortable-disabled');
|
|
33
|
+
|
|
34
|
+
const endpoint = container.dataset.sortableEndpoint;
|
|
35
|
+
const key = container.dataset.sortableKey || 'locations';
|
|
36
|
+
if (!endpoint) return;
|
|
37
|
+
|
|
38
|
+
const sortable = new Sortable(container, {
|
|
39
|
+
handle: '.drag-handle',
|
|
40
|
+
animation: 150,
|
|
41
|
+
ghostClass: 'sortable-ghost',
|
|
42
|
+
chosenClass: 'sortable-chosen',
|
|
43
|
+
onEnd(evt) {
|
|
44
|
+
if (evt.oldIndex === evt.newIndex) return;
|
|
45
|
+
|
|
46
|
+
const rows = Array.from(container.querySelectorAll('tr[data-id]'));
|
|
47
|
+
if (rows.length === 0) return;
|
|
48
|
+
|
|
49
|
+
// Collect existing sort_order values and sort them numerically
|
|
50
|
+
const sortOrders = rows
|
|
51
|
+
.map((row) => parseInt(row.dataset.sortOrder, 10))
|
|
52
|
+
.filter((n) => !isNaN(n))
|
|
53
|
+
.sort((a, b) => a - b);
|
|
54
|
+
|
|
55
|
+
if (sortOrders.length !== rows.length) return;
|
|
56
|
+
|
|
57
|
+
// Redistribute: assign sorted values in new DOM order
|
|
58
|
+
const payload = rows.map((row, i) => {
|
|
59
|
+
const newOrder = sortOrders[i];
|
|
60
|
+
// Optimistic UI: update data attribute and visible display
|
|
61
|
+
row.dataset.sortOrder = String(newOrder);
|
|
62
|
+
const display = row.querySelector('[data-sort-order-display]');
|
|
63
|
+
if (display) display.textContent = String(newOrder);
|
|
64
|
+
return { id: row.dataset.id, sort_order: newOrder };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Send to server
|
|
68
|
+
fetch(endpoint, {
|
|
69
|
+
method: 'PATCH',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ [key]: payload }),
|
|
72
|
+
})
|
|
73
|
+
.then((res) => {
|
|
74
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
75
|
+
return res.json();
|
|
76
|
+
})
|
|
77
|
+
.then(() => {
|
|
78
|
+
GrasprToast?.show({ message: 'Sort order updated.', status: 'success' });
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
GrasprToast?.show({ message: 'Failed to update sort order.', status: 'error' });
|
|
82
|
+
// Reload server state
|
|
83
|
+
if (typeof htmx?.trigger === 'function') {
|
|
84
|
+
htmx.trigger(container, 'refresh');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
instances.set(container, sortable);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function initAllSortables(root) {
|
|
94
|
+
const containers = root.querySelectorAll
|
|
95
|
+
? root.querySelectorAll('[data-sortable]')
|
|
96
|
+
: [];
|
|
97
|
+
|
|
98
|
+
// Also check if root itself is a sortable container
|
|
99
|
+
if (root instanceof HTMLElement && root.hasAttribute('data-sortable')) {
|
|
100
|
+
initSortable(root);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
containers.forEach((el) => initSortable(el));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Init on full page load
|
|
107
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
108
|
+
initAllSortables(document);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Re-init after HTMX swaps (covers self-loading tbody, boosted nav, search clear)
|
|
112
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
113
|
+
const target = e.detail?.target;
|
|
114
|
+
if (!target) return;
|
|
115
|
+
initAllSortables(target);
|
|
116
|
+
});
|