@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,111 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Table column sorting (delegated)
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Clickable <th data-sort="column"> headers that toggle asc/desc sorting.
|
|
5
|
+
// Sort state is persisted in URL query params (?sort=col&dir=asc).
|
|
6
|
+
// Fully self-registering — import for side effects only.
|
|
7
|
+
// Also exports initTableSort() for use in DOMContentLoaded/afterSwap orchestration.
|
|
8
|
+
|
|
9
|
+
import htmx from '../lib/htmx.js';
|
|
10
|
+
import { setUrlParam, getUrlParam, mergeHxVals } from './navigation.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply sort indicator classes to th[data-sort] headers based on current sort state.
|
|
14
|
+
* @param {Document|HTMLElement} root
|
|
15
|
+
*/
|
|
16
|
+
export function initTableSort(root = document) {
|
|
17
|
+
const sortCol = getUrlParam('sort');
|
|
18
|
+
const sortDir = getUrlParam('dir');
|
|
19
|
+
|
|
20
|
+
root.querySelectorAll('th[data-sort]').forEach((th) => {
|
|
21
|
+
th.classList.remove('sort-asc', 'sort-desc');
|
|
22
|
+
if (sortCol && th.dataset.sort === sortCol) {
|
|
23
|
+
th.classList.add(sortDir === 'desc' ? 'sort-desc' : 'sort-asc');
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Set hx-vals on sortable tbody elements so the initial API request includes sort params
|
|
28
|
+
if (sortCol) {
|
|
29
|
+
root.querySelectorAll('th[data-sort]').forEach((th) => {
|
|
30
|
+
const tbody = th.closest('table')?.querySelector('tbody');
|
|
31
|
+
if (tbody instanceof HTMLElement && !tbody.dataset.sortInit) {
|
|
32
|
+
mergeHxVals(tbody, { sort: sortCol, dir: sortDir || 'asc' });
|
|
33
|
+
tbody.dataset.sortInit = '1';
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Delegated click handler for sort headers
|
|
40
|
+
document.addEventListener('click', (e) => {
|
|
41
|
+
const th = e.target.closest('th[data-sort]');
|
|
42
|
+
if (!th) return;
|
|
43
|
+
|
|
44
|
+
const column = th.dataset.sort;
|
|
45
|
+
const tbody = th.closest('table')?.querySelector('tbody');
|
|
46
|
+
if (!(tbody instanceof HTMLElement)) return;
|
|
47
|
+
|
|
48
|
+
// Determine new direction: toggle if same column, else default to asc
|
|
49
|
+
const currentSort = getUrlParam('sort');
|
|
50
|
+
const currentDir = getUrlParam('dir');
|
|
51
|
+
let newDir = 'asc';
|
|
52
|
+
if (column === currentSort) {
|
|
53
|
+
newDir = currentDir === 'asc' ? 'desc' : 'asc';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Update sort indicator classes on all sibling headers
|
|
57
|
+
const thead = th.closest('thead');
|
|
58
|
+
if (thead) {
|
|
59
|
+
thead.querySelectorAll('th[data-sort]').forEach((sibling) => {
|
|
60
|
+
sibling.classList.remove('sort-asc', 'sort-desc');
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
th.classList.add(newDir === 'desc' ? 'sort-desc' : 'sort-asc');
|
|
64
|
+
|
|
65
|
+
// Reset pagination to page 1
|
|
66
|
+
const pageParam = tbody.getAttribute('data-pagination-param') || 'page';
|
|
67
|
+
mergeHxVals(tbody, { sort: column, dir: newDir, [pageParam]: 1 });
|
|
68
|
+
setUrlParam(pageParam, null);
|
|
69
|
+
|
|
70
|
+
// Update URL for bookmarkability
|
|
71
|
+
setUrlParam('sort', column);
|
|
72
|
+
setUrlParam('dir', newDir);
|
|
73
|
+
|
|
74
|
+
// Trigger refresh
|
|
75
|
+
htmx.process(tbody);
|
|
76
|
+
if (typeof htmx?.trigger === 'function') {
|
|
77
|
+
htmx.trigger(tbody, 'refresh');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Re-sync sort state on back/forward navigation
|
|
82
|
+
window.addEventListener('popstate', () => {
|
|
83
|
+
const sortCol = getUrlParam('sort');
|
|
84
|
+
const sortDir = getUrlParam('dir');
|
|
85
|
+
|
|
86
|
+
document.querySelectorAll('th[data-sort]').forEach((th) => {
|
|
87
|
+
th.classList.remove('sort-asc', 'sort-desc');
|
|
88
|
+
if (sortCol && th.dataset.sort === sortCol) {
|
|
89
|
+
th.classList.add(sortDir === 'desc' ? 'sort-desc' : 'sort-asc');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Update hx-vals on tbody elements
|
|
94
|
+
document.querySelectorAll('th[data-sort]').forEach((th) => {
|
|
95
|
+
const tbody = th.closest('table')?.querySelector('tbody');
|
|
96
|
+
if (tbody instanceof HTMLElement) {
|
|
97
|
+
if (sortCol) {
|
|
98
|
+
mergeHxVals(tbody, { sort: sortCol, dir: sortDir || 'asc' });
|
|
99
|
+
} else {
|
|
100
|
+
// Remove sort params from hx-vals when URL has none
|
|
101
|
+
let existing = {};
|
|
102
|
+
try {
|
|
103
|
+
existing = JSON.parse(tbody.getAttribute('hx-vals') || '{}');
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
delete existing.sort;
|
|
106
|
+
delete existing.dir;
|
|
107
|
+
tbody.setAttribute('hx-vals', JSON.stringify(existing));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// API Client
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Convenience wrapper around fetch for /api/ calls.
|
|
5
|
+
// Auto-handles CSRF headers, JSON content type, and body serialization.
|
|
6
|
+
// New code should use this; existing inline fetch() calls are protected
|
|
7
|
+
// by the global interceptor in core/csrf.js.
|
|
8
|
+
|
|
9
|
+
import { getCsrfToken, setCsrfToken } from './core/csrf.js';
|
|
10
|
+
|
|
11
|
+
const CSRF_HEADER = 'X-CSRF-Token';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch wrapper for API calls.
|
|
15
|
+
* @param {string} url - The API URL (e.g. '/api/users')
|
|
16
|
+
* @param {RequestInit & { body?: object | string }} [options={}]
|
|
17
|
+
* @returns {Promise<Response>}
|
|
18
|
+
*/
|
|
19
|
+
export function apiFetch(url, options = {}) {
|
|
20
|
+
const headers = new Headers(options.headers || {});
|
|
21
|
+
const token = getCsrfToken();
|
|
22
|
+
|
|
23
|
+
if (token) {
|
|
24
|
+
headers.set(CSRF_HEADER, token);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
headers.set('Accept', 'application/json');
|
|
28
|
+
|
|
29
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
30
|
+
|
|
31
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
32
|
+
if (!headers.has('Content-Type')) {
|
|
33
|
+
headers.set('Content-Type', 'application/json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
|
|
37
|
+
options = { ...options, body: JSON.stringify(options.body) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return fetch(url, { ...options, headers, credentials: 'same-origin' }).then(response => {
|
|
42
|
+
const newToken = response.headers.get(CSRF_HEADER);
|
|
43
|
+
if (newToken) setCsrfToken(newToken);
|
|
44
|
+
return response;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a debounced version of a function.
|
|
3
|
+
* @param {Function} fn - The function to debounce
|
|
4
|
+
* @param {number} delay - Delay in milliseconds
|
|
5
|
+
* @returns {Function} - Debounced function with a .cancel() method
|
|
6
|
+
*/
|
|
7
|
+
export function debounce(fn, delay) {
|
|
8
|
+
let timeoutId = null;
|
|
9
|
+
|
|
10
|
+
const debounced = (...args) => {
|
|
11
|
+
if (timeoutId !== null) {
|
|
12
|
+
clearTimeout(timeoutId);
|
|
13
|
+
}
|
|
14
|
+
timeoutId = setTimeout(() => {
|
|
15
|
+
timeoutId = null;
|
|
16
|
+
fn(...args);
|
|
17
|
+
}, delay);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
debounced.cancel = () => {
|
|
21
|
+
if (timeoutId !== null) {
|
|
22
|
+
clearTimeout(timeoutId);
|
|
23
|
+
timeoutId = null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return debounced;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Scrub user input for safe use in search queries.
|
|
32
|
+
* Removes potentially dangerous characters while preserving search functionality.
|
|
33
|
+
* @param {string} input - Raw user input
|
|
34
|
+
* @returns {string} - Scrubbed input
|
|
35
|
+
*/
|
|
36
|
+
export function scrubSearchInput(input) {
|
|
37
|
+
if (typeof input !== 'string') return '';
|
|
38
|
+
|
|
39
|
+
return input
|
|
40
|
+
// Remove HTML/script injection characters
|
|
41
|
+
.replace(/[<>]/g, '')
|
|
42
|
+
// Remove potential SQL injection characters (belt-and-suspenders with BE)
|
|
43
|
+
.replace(/[;'"\\]/g, '')
|
|
44
|
+
// Collapse multiple spaces into one
|
|
45
|
+
.replace(/\s+/g, ' ')
|
|
46
|
+
// Trim whitespace
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register generic/reusable Handlebars helpers.
|
|
3
|
+
* @param {typeof import('handlebars')} Handlebars
|
|
4
|
+
*/
|
|
5
|
+
export function registerHandlebarsHelpers(Handlebars) {
|
|
6
|
+
// ─── Partials ───
|
|
7
|
+
|
|
8
|
+
Handlebars.registerPartial('formButtons', `<div class="flex justify-end gap-3 pt-2">
|
|
9
|
+
<button type="button" class="border rounded px-3 py-2 hover:bg-slate-50" data-modal-close>
|
|
10
|
+
Cancel
|
|
11
|
+
</button>
|
|
12
|
+
<button type="submit" class="rounded px-3 py-2 bg-slate-900 text-white hover:bg-slate-800">
|
|
13
|
+
{{label}}
|
|
14
|
+
</button>
|
|
15
|
+
</div>`);
|
|
16
|
+
|
|
17
|
+
Handlebars.registerPartial('copyIdBtn', `<button
|
|
18
|
+
type="button"
|
|
19
|
+
class="copy-id-btn p-1 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600"
|
|
20
|
+
data-copy-value="{{id}}"
|
|
21
|
+
title="Copy ID"
|
|
22
|
+
>
|
|
23
|
+
<svg class="copy-icon w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
24
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
25
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
26
|
+
</svg>
|
|
27
|
+
<svg class="check-icon w-3.5 h-3.5 hidden text-green-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
28
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
29
|
+
</svg>
|
|
30
|
+
</button>`);
|
|
31
|
+
|
|
32
|
+
// ─── Utilities ───
|
|
33
|
+
|
|
34
|
+
// Parse a timestamp as UTC. MySQL datetimes arrive as "2026-03-11 14:30:00"
|
|
35
|
+
// with no timezone indicator — JS Date parsing is ambiguous without one.
|
|
36
|
+
// This normalizes to ISO 8601 with Z suffix so it's unambiguously UTC.
|
|
37
|
+
function parseUtcDate(value) {
|
|
38
|
+
if (value === null || value === undefined) return null;
|
|
39
|
+
const s = String(value).trim();
|
|
40
|
+
if (!s) return null;
|
|
41
|
+
// If it already has a timezone indicator (Z, +, -) leave it alone,
|
|
42
|
+
// otherwise treat as UTC by appending Z after converting space to T
|
|
43
|
+
const normalized = /[Z+\-]\d{0,4}:?\d{0,2}$/.test(s) ? s : s.replace(' ', 'T') + 'Z';
|
|
44
|
+
const d = new Date(normalized);
|
|
45
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Helpers ───
|
|
49
|
+
|
|
50
|
+
// Equality comparison helper
|
|
51
|
+
Handlebars.registerHelper('eq', (a, b) => a === b);
|
|
52
|
+
|
|
53
|
+
// Inequality comparison helper
|
|
54
|
+
Handlebars.registerHelper('neq', (a, b) => a !== b);
|
|
55
|
+
|
|
56
|
+
// Logical AND helper
|
|
57
|
+
Handlebars.registerHelper('and', function(...args) {
|
|
58
|
+
args.pop(); // Remove Handlebars options object
|
|
59
|
+
return args.every(Boolean);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Logical OR helper
|
|
63
|
+
Handlebars.registerHelper('or', function(...args) {
|
|
64
|
+
args.pop(); // Remove Handlebars options object
|
|
65
|
+
return args.some(Boolean);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
Handlebars.registerHelper('notin', function(value, ...rest) {
|
|
69
|
+
const options = rest.pop(); // Remove Handlebars options object
|
|
70
|
+
let listArgs = rest;
|
|
71
|
+
|
|
72
|
+
// If a single array was provided from context, use it
|
|
73
|
+
if (listArgs.length === 1 && Array.isArray(listArgs[0])) {
|
|
74
|
+
listArgs = listArgs[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!listArgs || !Array.isArray(listArgs)) {
|
|
78
|
+
return true; // treat missing list as "not in"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return listArgs.indexOf(value) === -1;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Truncate a string to a max length, adding ellipsis if truncated.
|
|
85
|
+
Handlebars.registerHelper('truncate', (value, length) => {
|
|
86
|
+
if (value === null || value === undefined) return '';
|
|
87
|
+
const s = String(value);
|
|
88
|
+
if (s.length <= length) return s;
|
|
89
|
+
return s.slice(0, length) + '…';
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Make a string all upper case
|
|
93
|
+
Handlebars.registerHelper('upper', (value) => {
|
|
94
|
+
if (value === null || value === undefined) return '';
|
|
95
|
+
const s = String(value);
|
|
96
|
+
return s.toUpperCase();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Convert snake_case to human-readable text: "on_pack_open" → "on pack open"
|
|
100
|
+
Handlebars.registerHelper('humanize', (value) => {
|
|
101
|
+
if (value === null || value === undefined) return '';
|
|
102
|
+
return String(value).replace(/_/g, ' ');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Convert snake_case to Title Case: "level_up" → "Level Up"
|
|
106
|
+
Handlebars.registerHelper('titleHumanize', (value) => {
|
|
107
|
+
if (value === null || value === undefined) return '';
|
|
108
|
+
return String(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Tree indent indicator — shows depth via left margin + ↳ arrow.
|
|
112
|
+
// sort_key format: "00" (root), "00.01" (depth 1), "00.01.ff" (depth 2), etc.
|
|
113
|
+
Handlebars.registerHelper('treeIndent', (sortKey) => {
|
|
114
|
+
if (!sortKey) return '';
|
|
115
|
+
const depth = (String(sortKey).match(/\./g) || []).length;
|
|
116
|
+
if (depth === 0) return '';
|
|
117
|
+
const ml = depth * 16;
|
|
118
|
+
return new Handlebars.SafeString(
|
|
119
|
+
`<span class="text-slate-300 inline-block" style="margin-left:${ml}px">↳</span> `
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Serialize a value to a JSON string (safe for embedding in HTML attributes).
|
|
124
|
+
Handlebars.registerHelper('json', (value) => {
|
|
125
|
+
if (value === null || value === undefined) return 'null';
|
|
126
|
+
return JSON.stringify(value);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Relative time helper: "3 minutes ago", "2 days ago", etc.
|
|
130
|
+
Handlebars.registerHelper('timeAgo', (value) => {
|
|
131
|
+
const d = parseUtcDate(value);
|
|
132
|
+
if (!d) return '';
|
|
133
|
+
|
|
134
|
+
const seconds = Math.floor((Date.now() - d.getTime()) / 1000);
|
|
135
|
+
if (seconds < 0) return 'just now';
|
|
136
|
+
|
|
137
|
+
const intervals = [
|
|
138
|
+
[60, 'second'],
|
|
139
|
+
[60, 'minute'],
|
|
140
|
+
[24, 'hour'],
|
|
141
|
+
[30, 'day'],
|
|
142
|
+
[12, 'month'],
|
|
143
|
+
[Infinity, 'year'],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
let remaining = seconds;
|
|
147
|
+
for (const [divisor, unit] of intervals) {
|
|
148
|
+
if (remaining < divisor) {
|
|
149
|
+
if (unit === 'second' && remaining < 10) return 'just now';
|
|
150
|
+
const n = Math.floor(remaining);
|
|
151
|
+
return n + ' ' + unit + (n !== 1 ? 's' : '') + ' ago';
|
|
152
|
+
}
|
|
153
|
+
remaining /= divisor;
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Format a UTC timestamp to the user's local timezone as a readable datetime string.
|
|
159
|
+
Handlebars.registerHelper('formatDateTime', (value) => {
|
|
160
|
+
const d = parseUtcDate(value);
|
|
161
|
+
if (!d) return '';
|
|
162
|
+
|
|
163
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
164
|
+
year: 'numeric',
|
|
165
|
+
month: 'short',
|
|
166
|
+
day: '2-digit',
|
|
167
|
+
hour: '2-digit',
|
|
168
|
+
minute: '2-digit',
|
|
169
|
+
}).format(d);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { registerHandlebarsHelpers } from './handlebars-helpers.js';
|
|
2
|
+
export { debounce, scrubSearchInput } from './debounce.js';
|
|
3
|
+
export { initCopyIdHandler } from './utils.js';
|
|
4
|
+
export { escapeHtml } from './escape-html.js';
|
|
5
|
+
export { populateSelect } from './populate-select.js';
|
|
6
|
+
export { getRouteParams } from './route-params.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Populate a <select> from a JSON API endpoint.
|
|
3
|
+
* Fetches { data: [{ id, name }, ...] } and appends <option> elements.
|
|
4
|
+
* @param {HTMLSelectElement} select
|
|
5
|
+
* @param {string} endpoint
|
|
6
|
+
* @param {string} [selectedId]
|
|
7
|
+
*/
|
|
8
|
+
export function populateSelect(select, endpoint, selectedId) {
|
|
9
|
+
fetch(endpoint)
|
|
10
|
+
.then(function(r) { return r.json(); })
|
|
11
|
+
.then(function(json) {
|
|
12
|
+
if (json.status !== 'success' || !json.data) return;
|
|
13
|
+
json.data.forEach(function(item) {
|
|
14
|
+
var opt = document.createElement('option');
|
|
15
|
+
opt.value = item.id;
|
|
16
|
+
opt.textContent = item.name;
|
|
17
|
+
if (selectedId && item.id === selectedId) opt.selected = true;
|
|
18
|
+
select.appendChild(opt);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { normalizePath } from '../core/navigation.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract route parameters from the current URL based on a route pattern.
|
|
5
|
+
* @param {string} pattern - Route pattern with [param] placeholders (e.g., '/series/[seriesId]/collections/')
|
|
6
|
+
* @param {string} [url] - URL to extract from (defaults to current pathname)
|
|
7
|
+
* @returns {Object} - Object mapping parameter names to values
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // On page /series/abc123/collections/
|
|
11
|
+
* getRouteParams('/series/[seriesId]/collections/')
|
|
12
|
+
* // Returns: { seriesId: 'abc123' }
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Multiple params
|
|
16
|
+
* getRouteParams('/series/[seriesId]/collections/[collectionId]/')
|
|
17
|
+
* // On /series/abc123/collections/xyz789/
|
|
18
|
+
* // Returns: { seriesId: 'abc123', collectionId: 'xyz789' }
|
|
19
|
+
*/
|
|
20
|
+
export function getRouteParams(pattern, url = window.location.pathname) {
|
|
21
|
+
const params = {};
|
|
22
|
+
|
|
23
|
+
const urlNorm = normalizePath(url);
|
|
24
|
+
const patternNorm = normalizePath(pattern);
|
|
25
|
+
|
|
26
|
+
const urlSegments = urlNorm.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
|
|
27
|
+
const patternSegments = patternNorm.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
|
|
28
|
+
|
|
29
|
+
if (urlSegments.length !== patternSegments.length) {
|
|
30
|
+
return params; // Pattern doesn't match, return empty object
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
34
|
+
const urlSeg = urlSegments[i];
|
|
35
|
+
const patternSeg = patternSegments[i];
|
|
36
|
+
|
|
37
|
+
// Check if this is a parameter segment
|
|
38
|
+
if (patternSeg.startsWith('[') && patternSeg.endsWith(']')) {
|
|
39
|
+
const paramName = patternSeg.slice(1, -1); // Remove [ and ]
|
|
40
|
+
params[paramName] = urlSeg;
|
|
41
|
+
} else {
|
|
42
|
+
// Static segment should match exactly
|
|
43
|
+
if (patternSeg !== urlSeg) {
|
|
44
|
+
return {}; // Mismatch, return empty object
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return params;
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialize copy-to-clipboard button handler (delegated).
|
|
3
|
+
* Buttons should have class `.copy-id-btn` and `data-copy-value` attribute.
|
|
4
|
+
* Icons inside: `.copy-icon` (clipboard) and `.check-icon` (checkmark, hidden).
|
|
5
|
+
*/
|
|
6
|
+
export function initCopyIdHandler() {
|
|
7
|
+
document.addEventListener('click', (e) => {
|
|
8
|
+
const btn = e.target.closest('.copy-id-btn');
|
|
9
|
+
if (!btn) return;
|
|
10
|
+
|
|
11
|
+
const value = btn.dataset.copyValue;
|
|
12
|
+
if (!value) return;
|
|
13
|
+
|
|
14
|
+
navigator.clipboard.writeText(value).then(() => {
|
|
15
|
+
const copyIcon = btn.querySelector('.copy-icon');
|
|
16
|
+
const checkIcon = btn.querySelector('.check-icon');
|
|
17
|
+
if (copyIcon && checkIcon) {
|
|
18
|
+
copyIcon.classList.add('hidden');
|
|
19
|
+
checkIcon.classList.remove('hidden');
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
copyIcon.classList.remove('hidden');
|
|
22
|
+
checkIcon.classList.add('hidden');
|
|
23
|
+
}, 1500);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Graspr Framework — barrel export
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Public API for apps to import from '@phillipsharring/graspr-framework'.
|
|
5
|
+
// Side-effect modules (csrf, boosted-nav, auth-state, forms, search, sortable)
|
|
6
|
+
// are NOT imported here — use '@phillipsharring/graspr-framework/init' for those.
|
|
7
|
+
|
|
8
|
+
// Auth
|
|
9
|
+
export { checkAuth, getAuthData, refreshAuthData } from './auth.js';
|
|
10
|
+
|
|
11
|
+
// API client
|
|
12
|
+
export { apiFetch } from './fetch-client.js';
|
|
13
|
+
|
|
14
|
+
// Core — pagination & table sort (init functions)
|
|
15
|
+
export { initPagination } from './core/pagination.js';
|
|
16
|
+
export { initTableSort } from './core/table-sort.js';
|
|
17
|
+
|
|
18
|
+
// Core — auth-state (configurable)
|
|
19
|
+
export { registerAdminPermissionPrefixes } from './core/auth-state.js';
|
|
20
|
+
|
|
21
|
+
// Core — navigation utilities
|
|
22
|
+
export {
|
|
23
|
+
normalizePath,
|
|
24
|
+
setActiveNav,
|
|
25
|
+
setUrlParam,
|
|
26
|
+
getUrlParam,
|
|
27
|
+
getUrlParamInt,
|
|
28
|
+
mergeHxVals,
|
|
29
|
+
} from './core/navigation.js';
|
|
30
|
+
|
|
31
|
+
// Core — CSRF token access
|
|
32
|
+
export { getCsrfToken, setCsrfToken } from './core/csrf.js';
|
|
33
|
+
|
|
34
|
+
// UI — toast
|
|
35
|
+
export {
|
|
36
|
+
GrasprToast,
|
|
37
|
+
registerToastHelpers,
|
|
38
|
+
initToastEventHandlers,
|
|
39
|
+
openToast,
|
|
40
|
+
closeToast,
|
|
41
|
+
} from './ui/toast.js';
|
|
42
|
+
|
|
43
|
+
// UI — modal
|
|
44
|
+
export {
|
|
45
|
+
setOnModalCloseWithConfirm,
|
|
46
|
+
getGlobalModal,
|
|
47
|
+
getGlobalModalDialog,
|
|
48
|
+
getGlobalModalHeader,
|
|
49
|
+
getGlobalModalCloseButton,
|
|
50
|
+
isGlobalModalOpen,
|
|
51
|
+
openGlobalModal,
|
|
52
|
+
closeGlobalModal,
|
|
53
|
+
} from './ui/modal.js';
|
|
54
|
+
|
|
55
|
+
// UI — modal form
|
|
56
|
+
export { openFormModal } from './ui/modal-form.js';
|
|
57
|
+
|
|
58
|
+
// UI — confirm dialog
|
|
59
|
+
export { GrasprConfirm } from './ui/confirm-dialog.js';
|
|
60
|
+
|
|
61
|
+
// UI — click burst
|
|
62
|
+
export { createBurst, attachClickBurst, initClickBurst } from './ui/click-burst.js';
|
|
63
|
+
|
|
64
|
+
// UI — typeahead
|
|
65
|
+
export { createTypeahead } from './ui/typeahead.js';
|
|
66
|
+
|
|
67
|
+
// Helpers
|
|
68
|
+
export { registerHandlebarsHelpers } from './helpers/handlebars-helpers.js';
|
|
69
|
+
export { initCopyIdHandler } from './helpers/utils.js';
|
|
70
|
+
export { escapeHtml } from './helpers/escape-html.js';
|
|
71
|
+
export { populateSelect } from './helpers/populate-select.js';
|
|
72
|
+
export { getRouteParams } from './helpers/route-params.js';
|
|
73
|
+
export { debounce, scrubSearchInput } from './helpers/debounce.js';
|
package/src/init.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Graspr Framework initialization (side effects)
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Import this module to register all framework event listeners,
|
|
5
|
+
// CSRF interceptors, boosted-nav handlers, form error handling, etc.
|
|
6
|
+
// Order matters: csrf must be before auth-state.
|
|
7
|
+
|
|
8
|
+
import './core/csrf.js';
|
|
9
|
+
import './core/boosted-nav.js';
|
|
10
|
+
import './core/auth-state.js';
|
|
11
|
+
import './core/forms.js';
|
|
12
|
+
import './core/search.js';
|
|
13
|
+
import './core/sortable.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import htmx from './htmx.js';
|
|
2
|
+
import Handlebars from 'handlebars';
|
|
3
|
+
|
|
4
|
+
htmx.defineExtension('client-side-templates', {
|
|
5
|
+
transformResponse(text, xhr, elt) {
|
|
6
|
+
const tplHost = htmx.closest(elt, '[handlebars-template], [handlebars-array-template]');
|
|
7
|
+
if (!tplHost) return text;
|
|
8
|
+
|
|
9
|
+
// Boosted <a> links inside a template-attributed container inherit the
|
|
10
|
+
// attribute, but their responses are full HTML pages — not JSON.
|
|
11
|
+
// Skip transformation so the beforeSwap handler can fix the target.
|
|
12
|
+
if (elt instanceof HTMLAnchorElement && !elt.hasAttribute('hx-get') && !elt.hasAttribute('hx-post')) {
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isArray = tplHost.hasAttribute('handlebars-array-template');
|
|
17
|
+
const templateId = tplHost.getAttribute(isArray ? 'handlebars-array-template' : 'handlebars-template');
|
|
18
|
+
|
|
19
|
+
const templateEl = htmx.find('#' + templateId);
|
|
20
|
+
if (!templateEl) throw new Error('Unknown handlebars template: ' + templateId);
|
|
21
|
+
|
|
22
|
+
let data;
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(text);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error('Response was not valid JSON for handlebars template: ' + templateId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// innerHTML escapes ">" to ">" in text nodes (per the HTML serialization
|
|
30
|
+
// spec), which mangles Handlebars partial calls: {{> partial}} → {{> partial}}.
|
|
31
|
+
// Reverse this specific escaping before compiling.
|
|
32
|
+
const render = Handlebars.compile(templateEl.innerHTML.replaceAll('{{>', '{{>'));
|
|
33
|
+
|
|
34
|
+
// Object templates: spread `data` into the top level so templates can use
|
|
35
|
+
// both {{message}}/{{status}} (for toasts) and {{name}}/{{id}} (for entities).
|
|
36
|
+
// Envelope props (status, message) overlay record props — toasts need
|
|
37
|
+
// {{status}} = "success"/"error". Entity templates that collide with envelope
|
|
38
|
+
// keys (e.g. a `status` column) should use {{data.status}} instead.
|
|
39
|
+
if (!isArray) {
|
|
40
|
+
const hasDataObject = data && typeof data === 'object' && 'data' in data &&
|
|
41
|
+
typeof data.data === 'object' && !Array.isArray(data.data);
|
|
42
|
+
const renderData = hasDataObject
|
|
43
|
+
? { ...data.data, ...data, data: data.data } // spread data props, then overlay top-level (status/message win)
|
|
44
|
+
: data;
|
|
45
|
+
return render(renderData);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Array templates: normalize to { data: Array, meta?: Object }
|
|
49
|
+
// Supports both raw arrays and { data: [...], meta: {...} } responses
|
|
50
|
+
const isArrayResponse = Array.isArray(data);
|
|
51
|
+
const rows = isArrayResponse ? data : Array.isArray(data?.data) ? data.data : [];
|
|
52
|
+
const meta = !isArrayResponse && data && typeof data === 'object' ? data.meta ?? null : null;
|
|
53
|
+
|
|
54
|
+
// Stash table sort meta on the element for the table-sort module
|
|
55
|
+
if (meta?.table_sorts) {
|
|
56
|
+
tplHost.dataset.tableSorts = JSON.stringify(meta.table_sorts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Optional: render pagination from meta as a side effect
|
|
60
|
+
const pagTargetSel = tplHost.getAttribute('data-pagination-target');
|
|
61
|
+
const pagTemplateId = tplHost.getAttribute('data-pagination-template');
|
|
62
|
+
const pagSource = tplHost.getAttribute('data-pagination-source') || '';
|
|
63
|
+
const pagParam = tplHost.getAttribute('data-pagination-param') || 'page';
|
|
64
|
+
if (pagTargetSel && pagTemplateId) {
|
|
65
|
+
const targetEl = document.querySelector(pagTargetSel);
|
|
66
|
+
const tpl = document.getElementById(pagTemplateId);
|
|
67
|
+
if (targetEl instanceof HTMLElement && tpl instanceof HTMLTemplateElement) {
|
|
68
|
+
const pagRender = Handlebars.compile(tpl.innerHTML.replaceAll('{{>', '{{>'));
|
|
69
|
+
targetEl.innerHTML = pagRender({ meta, source: pagSource, param: pagParam });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return render({ data: rows, meta });
|
|
74
|
+
},
|
|
75
|
+
});
|
package/src/lib/htmx.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import htmx from 'htmx.org';
|
|
2
|
+
|
|
3
|
+
// Ensure there's exactly one shared HTMX instance, available both as an ES module
|
|
4
|
+
// import and as a global for any inline scripts / extensions that expect `window.htmx`.
|
|
5
|
+
window.htmx = window.htmx || htmx;
|
|
6
|
+
|
|
7
|
+
export default window.htmx;
|