@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
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Graspr Framework
|
|
2
|
+
|
|
3
|
+
A frontend framework for building server-driven web applications with **HTMX + Handlebars + Tailwind CSS**.
|
|
4
|
+
|
|
5
|
+
Graspr handles the hard parts of HTMX-based apps: boosted navigation, auth-gated widgets, modal/toast systems, CSRF token management, form error handling, and client-side template rendering — so you can focus on your pages and domain logic.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @phillipsharring/graspr-framework
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies (your app must install these):
|
|
14
|
+
```bash
|
|
15
|
+
npm install htmx.org handlebars sortablejs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For a ready-to-go project structure, use the [Graspr App Skeleton](https://github.com/phillipsharring/graspr-app-skeleton).
|
|
19
|
+
|
|
20
|
+
## What's Included
|
|
21
|
+
|
|
22
|
+
### Core Infrastructure (`core/`)
|
|
23
|
+
- **boosted-nav** — fixes HTMX boosted navigation edge cases (inherited targets, `hx-select` overrides, layout mismatch detection)
|
|
24
|
+
- **csrf** — global `fetch()` interceptor + HTMX hook for automatic CSRF token headers
|
|
25
|
+
- **auth-state** — auth-gated UI orchestration (`auth-load` events, permission gating, login modal, 401/403 handling)
|
|
26
|
+
- **forms** — inline form error display, modal form lifecycle, success redirect/refresh patterns
|
|
27
|
+
- **pagination** — paginated table controls with URL param syncing
|
|
28
|
+
- **search** — debounced search input with HTMX integration
|
|
29
|
+
- **sortable** — drag-and-drop reordering via SortableJS wrapper
|
|
30
|
+
- **table-sort** — clickable column header sorting with URL persistence
|
|
31
|
+
- **navigation** — URL helpers, active nav highlighting
|
|
32
|
+
|
|
33
|
+
### UI Widgets (`ui/`)
|
|
34
|
+
- **modal** — global modal state machine with focus management and overlay/escape handling
|
|
35
|
+
- **modal-form** — modal form populator (set fields, method, clear errors, focus)
|
|
36
|
+
- **toast** — toast notification system with auto-dismiss
|
|
37
|
+
- **confirm-dialog** — confirmation dialog with optional progress mode for batch operations
|
|
38
|
+
- **typeahead** — autocomplete widget factory with keyboard navigation
|
|
39
|
+
- **click-burst** — visual click feedback animation
|
|
40
|
+
|
|
41
|
+
### HTMX Extensions (`lib/`)
|
|
42
|
+
- **json-enc** — JSON encoding extension for HTMX requests
|
|
43
|
+
- **client-side-templates** — Handlebars template rendering for HTMX JSON responses
|
|
44
|
+
|
|
45
|
+
### Helpers (`helpers/`)
|
|
46
|
+
- **handlebars-helpers** — generic Handlebars helpers (eq, neq, and, or, truncate, timeAgo, formatDateTime, json, treeIndent, etc.)
|
|
47
|
+
- **escape-html** — HTML escape utility
|
|
48
|
+
- **populate-select** — `<select>` field populator
|
|
49
|
+
- **route-params** — URL parameter extraction for dynamic routes (`[id]` patterns)
|
|
50
|
+
- **debounce** — debounce utility + search input sanitization
|
|
51
|
+
|
|
52
|
+
### Auth (`auth.js`)
|
|
53
|
+
- Single `/api/auth/me` call per page load, cached
|
|
54
|
+
- `checkAuth()` — returns `Promise<boolean>`
|
|
55
|
+
- `getAuthData()` — returns full auth response with permissions
|
|
56
|
+
- `refreshAuthData()` — invalidates cache and re-fetches
|
|
57
|
+
|
|
58
|
+
### API Client (`fetch-client.js`)
|
|
59
|
+
- `apiFetch(url, options)` — wraps `fetch()` with CSRF headers, JSON content type, body serialization
|
|
60
|
+
|
|
61
|
+
### Styles (`styles/base.css`)
|
|
62
|
+
- Form error styles, HTMX request dimming, modal/takeover animations, sortable drag-and-drop, table sort headers, confirm dialog, active nav highlighting
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Import everything at once
|
|
67
|
+
```js
|
|
68
|
+
import {
|
|
69
|
+
GrasprToast, openFormModal, GrasprConfirm,
|
|
70
|
+
initPagination, initTableSort,
|
|
71
|
+
getRouteParams, escapeHtml,
|
|
72
|
+
} from '@phillipsharring/graspr-framework';
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Side-effect initialization
|
|
76
|
+
```js
|
|
77
|
+
// Registers CSRF interceptors, boosted-nav handlers, auth-state listeners,
|
|
78
|
+
// form error handling, search, and sortable — in the correct order.
|
|
79
|
+
import '@phillipsharring/graspr-framework/init';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### HTMX extensions (import after setting window.Handlebars)
|
|
83
|
+
```js
|
|
84
|
+
import '@phillipsharring/graspr-framework/src/lib/json-enc.js';
|
|
85
|
+
import '@phillipsharring/graspr-framework/src/lib/client-side-templates.js';
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Styles
|
|
89
|
+
```css
|
|
90
|
+
@import 'tailwindcss';
|
|
91
|
+
@source "../../content/**/*.html";
|
|
92
|
+
@source "../**/*.js";
|
|
93
|
+
@import '@phillipsharring/graspr-framework/styles/base.css';
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Configurable auth permissions
|
|
97
|
+
```js
|
|
98
|
+
import { registerAdminPermissionPrefixes } from '@phillipsharring/graspr-framework';
|
|
99
|
+
|
|
100
|
+
registerAdminPermissionPrefixes([
|
|
101
|
+
['/admin/design/', 'design.access'],
|
|
102
|
+
['/admin/story/', 'story.access'],
|
|
103
|
+
['/admin/', 'admin.access'],
|
|
104
|
+
]);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Designed For
|
|
108
|
+
|
|
109
|
+
Graspr is the frontend companion to [Handlr Framework](https://github.com/phillipsharring/handlr-framework) (PHP backend), but works with any backend that serves JSON APIs and HTML pages. The auth system expects a `/api/auth/me` endpoint; everything else is configurable.
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
- Vite 7+
|
|
114
|
+
- Tailwind CSS 4+
|
|
115
|
+
- Node.js 18+
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phillipsharring/graspr-framework",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "HTMX + Handlebars + Tailwind frontend framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./init": "./src/init.js",
|
|
9
|
+
"./styles/*": "./styles/*",
|
|
10
|
+
"./src/*": "./src/*"
|
|
11
|
+
},
|
|
12
|
+
"author": "Phillip Harrington <phillip@phillipharrington.com> (https://phillipharrington.com/)",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"handlebars": "^4.7.0",
|
|
16
|
+
"htmx.org": "^2.0.0",
|
|
17
|
+
"sortablejs": "^1.15.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Centralized Auth Check
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Makes a single /api/auth/me call per page load and caches the result.
|
|
5
|
+
// Import `checkAuth` for a boolean, or `getAuthData` for the full response.
|
|
6
|
+
// Call `refreshAuthData()` to invalidate the cache and re-fetch.
|
|
7
|
+
|
|
8
|
+
function fetchAuthData() {
|
|
9
|
+
return fetch('/api/auth/me', { credentials: 'same-origin' })
|
|
10
|
+
.then(r => r.json())
|
|
11
|
+
.then(data => ({
|
|
12
|
+
authenticated: !!data.data?.authenticated,
|
|
13
|
+
username: data.data?.user?.username || null,
|
|
14
|
+
permissions: data.meta?.permissions || [],
|
|
15
|
+
}))
|
|
16
|
+
.catch(() => ({ authenticated: false, username: null, permissions: [] }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let authPromise = fetchAuthData();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves with `true` when the user is authenticated, `false` otherwise.
|
|
23
|
+
* The check runs once per page load; subsequent calls return the cached result.
|
|
24
|
+
* @returns {Promise<boolean>}
|
|
25
|
+
*/
|
|
26
|
+
export function checkAuth() {
|
|
27
|
+
return authPromise.then(d => d.authenticated);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves with the full auth data object: { authenticated, username, permissions }.
|
|
32
|
+
* Same cache as checkAuth — no extra network call.
|
|
33
|
+
* @returns {Promise<{authenticated: boolean, username: string|null, permissions: string[]}>}
|
|
34
|
+
*/
|
|
35
|
+
export function getAuthData() {
|
|
36
|
+
return authPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Invalidates the cached auth data and re-fetches from /api/auth/me.
|
|
41
|
+
* Returns the fresh auth data promise.
|
|
42
|
+
* @returns {Promise<{authenticated: boolean, username: string|null, permissions: string[]}>}
|
|
43
|
+
*/
|
|
44
|
+
export function refreshAuthData() {
|
|
45
|
+
authPromise = fetchAuthData();
|
|
46
|
+
return authPromise;
|
|
47
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// Auth-gated UI
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Manages authentication state visibility, auth-gated widget triggering,
|
|
5
|
+
// admin permission checks, login modal, and 401 interception.
|
|
6
|
+
// Fully self-registering — import for side effects only.
|
|
7
|
+
|
|
8
|
+
import htmx from '../lib/htmx.js';
|
|
9
|
+
import { checkAuth, getAuthData, refreshAuthData } from '../auth.js';
|
|
10
|
+
import { isGlobalModalOpen } from '../ui/index.js';
|
|
11
|
+
import { openFormModal } from '../ui/modal-form.js';
|
|
12
|
+
import { GrasprToast } from '../ui/toast.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------
|
|
15
|
+
// Header widget refresh
|
|
16
|
+
// ---------------------------
|
|
17
|
+
|
|
18
|
+
function refreshHeaderWidgets({ selector = '[data-header-widget]', eventName = 'refresh' } = {}) {
|
|
19
|
+
// Header widgets live outside #app, so they won't be re-initialized by page swaps.
|
|
20
|
+
// Mark any widget with `data-header-widget` and give it `hx-trigger="auth-load, refresh"`.
|
|
21
|
+
if (typeof htmx?.trigger !== 'function') return;
|
|
22
|
+
document.querySelectorAll(selector).forEach((el) => {
|
|
23
|
+
if (el instanceof HTMLElement) {
|
|
24
|
+
htmx.trigger(el, eventName);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------
|
|
30
|
+
// Auth state application
|
|
31
|
+
// ---------------------------
|
|
32
|
+
// Reveals auth-dependent elements. Idempotent — safe to call after every swap.
|
|
33
|
+
|
|
34
|
+
const authPending = new WeakSet();
|
|
35
|
+
|
|
36
|
+
function applyAuthState(authData) {
|
|
37
|
+
const authenticated = typeof authData === 'boolean' ? authData : authData.authenticated;
|
|
38
|
+
const username = typeof authData === 'object' ? authData.username : null;
|
|
39
|
+
|
|
40
|
+
// Auth links — both start hidden; reveal the correct one.
|
|
41
|
+
const loginLink = document.querySelector('[data-auth-login]');
|
|
42
|
+
const logoutLink = document.querySelector('[data-auth-logout]');
|
|
43
|
+
|
|
44
|
+
if (authenticated) {
|
|
45
|
+
logoutLink?.removeAttribute('hidden');
|
|
46
|
+
// Show username in the user menu button
|
|
47
|
+
const usernameEl = document.getElementById('user-menu-username');
|
|
48
|
+
if (usernameEl && username) {
|
|
49
|
+
usernameEl.textContent = username;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
loginLink?.removeAttribute('hidden');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Widgets that require an authenticated session.
|
|
56
|
+
// Track triggered elements in a WeakSet so each element only fires once,
|
|
57
|
+
// even if multiple afterSwap/afterSettle handlers call applyAuthState while
|
|
58
|
+
// requests are still in flight. New elements (e.g. after boosted nav swaps
|
|
59
|
+
// #app) won't be in the set and will be triggered normally.
|
|
60
|
+
if (authenticated && typeof htmx?.trigger === 'function') {
|
|
61
|
+
document.querySelectorAll('[data-requires-auth]').forEach(el => {
|
|
62
|
+
if (!el.children.length && !authPending.has(el)) {
|
|
63
|
+
authPending.add(el);
|
|
64
|
+
htmx.trigger(el, 'auth-load');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Permission-gated elements — reveal if user has the required permission.
|
|
70
|
+
// Uses cached getAuthData(), no extra network call.
|
|
71
|
+
if (authenticated) {
|
|
72
|
+
applyPermissionGating();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If not authenticated and the page has auth-required content inside #app,
|
|
76
|
+
// prompt the user to log in. (showLoginModal clears #app opacity itself.)
|
|
77
|
+
if (!authenticated && document.querySelector('#app [data-requires-auth]')) {
|
|
78
|
+
showLoginModal();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Clear auth opacity gate for non-admin layouts.
|
|
83
|
+
// Admin has its own permission-aware clearing in checkAdminPermissions().
|
|
84
|
+
const app = document.getElementById('app');
|
|
85
|
+
if (app && app.dataset?.layout !== 'admin') {
|
|
86
|
+
app.style.opacity = '';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------
|
|
91
|
+
// Permission-gated elements
|
|
92
|
+
// ---------------------------
|
|
93
|
+
// Elements with data-requires-permission="some.permission" start hidden
|
|
94
|
+
// and are revealed only if the user has that permission.
|
|
95
|
+
|
|
96
|
+
function applyPermissionGating() {
|
|
97
|
+
const els = document.querySelectorAll('[data-requires-permission]');
|
|
98
|
+
if (!els.length) return;
|
|
99
|
+
|
|
100
|
+
getAuthData().then(({ permissions }) => {
|
|
101
|
+
els.forEach(el => {
|
|
102
|
+
if (permissions.includes(el.dataset.requiresPermission)) {
|
|
103
|
+
el.removeAttribute('hidden');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Initial auth check + apply (pass full data for username).
|
|
110
|
+
getAuthData().then(applyAuthState);
|
|
111
|
+
|
|
112
|
+
// ---------------------------
|
|
113
|
+
// Admin permission checks
|
|
114
|
+
// ---------------------------
|
|
115
|
+
// Derives required permission from URL prefix — no per-page attributes needed.
|
|
116
|
+
// Apps register their own prefixes via registerAdminPermissionPrefixes().
|
|
117
|
+
|
|
118
|
+
let adminPermissionPrefixes = [];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register URL-prefix → permission mappings for admin permission checks.
|
|
122
|
+
* More specific prefixes should come first (e.g. '/admin/design/' before '/admin/').
|
|
123
|
+
* @param {Array<[string, string]>} prefixes - Array of [urlPrefix, permissionName] pairs
|
|
124
|
+
*/
|
|
125
|
+
export function registerAdminPermissionPrefixes(prefixes) {
|
|
126
|
+
adminPermissionPrefixes = prefixes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkAdminPermissions(appEl) {
|
|
130
|
+
if (appEl.dataset?.layout !== 'admin') return;
|
|
131
|
+
|
|
132
|
+
const path = window.location.pathname;
|
|
133
|
+
const required = adminPermissionPrefixes.find(([prefix]) => path.startsWith(prefix))?.[1];
|
|
134
|
+
if (!required) return;
|
|
135
|
+
|
|
136
|
+
getAuthData().then(({ authenticated, permissions }) => {
|
|
137
|
+
if (!authenticated) {
|
|
138
|
+
showLoginModal();
|
|
139
|
+
} else if (!permissions.includes(required)) {
|
|
140
|
+
window.location.href = '/game/';
|
|
141
|
+
return; // don't reveal — redirecting
|
|
142
|
+
}
|
|
143
|
+
appEl.style.opacity = '';
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------
|
|
148
|
+
// 401 / Unauthenticated → Login Modal
|
|
149
|
+
// ---------------------------
|
|
150
|
+
|
|
151
|
+
function showLoginModal() {
|
|
152
|
+
// Drop a login link into #app so there's something to click if the modal is dismissed
|
|
153
|
+
const app = document.getElementById('app');
|
|
154
|
+
if (app && !app.querySelector('[data-login-prompt]')) {
|
|
155
|
+
app.innerHTML = `
|
|
156
|
+
<div data-login-prompt class="flex items-center justify-center min-h-[60vh]">
|
|
157
|
+
<a href="/login/"
|
|
158
|
+
class="rounded px-6 py-3 bg-slate-900 text-white hover:bg-slate-800 text-lg no-underline"
|
|
159
|
+
>Login</a>
|
|
160
|
+
</div>`;
|
|
161
|
+
app.style.opacity = '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isGlobalModalOpen()) return;
|
|
165
|
+
openFormModal({
|
|
166
|
+
templateId: 'login-form-template',
|
|
167
|
+
title: 'Login',
|
|
168
|
+
size: 'sm',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Catch 401/403 responses before swap
|
|
173
|
+
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
|
174
|
+
const xhr = e.detail?.xhr;
|
|
175
|
+
if (xhr?.status === 401) {
|
|
176
|
+
e.detail.shouldSwap = false;
|
|
177
|
+
showLoginModal();
|
|
178
|
+
} else if (xhr?.status === 403) {
|
|
179
|
+
// CSRF failure — the response includes a fresh token (captured by
|
|
180
|
+
// the htmx:afterRequest hook in csrf.js), so the next request will work.
|
|
181
|
+
e.detail.shouldSwap = false;
|
|
182
|
+
GrasprToast.show({ message: 'Session expired, please try again.', status: 'warning' });
|
|
183
|
+
}
|
|
184
|
+
}, true); // Use capture to run before other beforeSwap handlers
|
|
185
|
+
|
|
186
|
+
// ---------------------------
|
|
187
|
+
// Self-registered lifecycle handlers
|
|
188
|
+
// ---------------------------
|
|
189
|
+
|
|
190
|
+
// Check admin permissions on full page load.
|
|
191
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
192
|
+
const app = document.getElementById('app');
|
|
193
|
+
if (app) checkAdminPermissions(app);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// On #app swap: refresh header widgets + re-check admin permissions.
|
|
197
|
+
document.body.addEventListener('htmx:afterSwap', (e) => {
|
|
198
|
+
const target = e.detail?.target;
|
|
199
|
+
|
|
200
|
+
if (target && target.id === 'app') {
|
|
201
|
+
checkAuth().then(auth => { if (auth) refreshHeaderWidgets(); });
|
|
202
|
+
// outerHTML swap detaches the old target — grab the live element from DOM
|
|
203
|
+
const app = document.getElementById('app');
|
|
204
|
+
if (app) checkAdminPermissions(app);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Fire auth-load AFTER settle, not after swap.
|
|
209
|
+
// HTMX lifecycle: beforeSwap → DOM swap → afterSwap → settle (processes hx-trigger
|
|
210
|
+
// etc. on new elements) → afterSettle. Firing auth-load in afterSwap meant the
|
|
211
|
+
// custom event fired before HTMX had wired up hx-trigger="auth-load" on the new
|
|
212
|
+
// elements — so nothing was listening yet.
|
|
213
|
+
document.body.addEventListener('htmx:afterSettle', (e) => {
|
|
214
|
+
const target = e.detail?.target;
|
|
215
|
+
|
|
216
|
+
// Skip widget responses — otherwise the first widget's afterSettle would
|
|
217
|
+
// re-trigger auth-load on sibling widgets that haven't loaded yet.
|
|
218
|
+
if (!target?.closest('[data-requires-auth]')) {
|
|
219
|
+
getAuthData().then(applyAuthState);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Re-fetch /api/auth/me and re-apply auth state (updates header username, etc.).
|
|
224
|
+
// Any code can fire this: document.body.dispatchEvent(new CustomEvent('auth-refresh'))
|
|
225
|
+
document.body.addEventListener('auth-refresh', () => {
|
|
226
|
+
refreshAuthData().then(applyAuthState);
|
|
227
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ----------------------------
|
|
2
|
+
// Boosted-nav infrastructure
|
|
3
|
+
// ----------------------------
|
|
4
|
+
// Handles three concerns for HTMX apps with hx-boost="true" on <body>:
|
|
5
|
+
//
|
|
6
|
+
// 1. hx-select override: <body> has hx-select="#app" for boosted nav. Non-boosted
|
|
7
|
+
// elements (buttons, forms) inherit it, but their JSON/template responses don't
|
|
8
|
+
// contain #app — so hx-select filters everything out. Fix: clear inherited
|
|
9
|
+
// hx-select for non-boosted requests via selectOverride = 'unset'.
|
|
10
|
+
//
|
|
11
|
+
// 2. Inherited target fix: Self-loading elements (tbody, detail divs) use
|
|
12
|
+
// hx-target="this". Boosted <a> links inside them inherit that target, causing
|
|
13
|
+
// the next page to load inside the table/div. Detects and redirects to #app.
|
|
14
|
+
//
|
|
15
|
+
// 3. Layout mismatch: When boosted nav crosses layouts (e.g. game → admin), only
|
|
16
|
+
// #app swaps — the chrome stays wrong. Detects layout differences and forces a
|
|
17
|
+
// full page reload.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if an element is a boosted page navigation link (not an explicit hx-get/hx-post).
|
|
21
|
+
*/
|
|
22
|
+
export function isBoostedPageNav(elt) {
|
|
23
|
+
return elt instanceof HTMLAnchorElement
|
|
24
|
+
&& !elt.hasAttribute('hx-get')
|
|
25
|
+
&& !elt.hasAttribute('hx-post');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractLayoutFromResponse(html) {
|
|
29
|
+
const match = html.match(/id=["']app["'][^>]*data-layout=["']([^"']+)["']/);
|
|
30
|
+
return match?.[1] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// hx-select override for non-boosted elements
|
|
34
|
+
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
|
35
|
+
const elt = e.detail.requestConfig?.elt;
|
|
36
|
+
if (!elt || isBoostedPageNav(elt)) return;
|
|
37
|
+
|
|
38
|
+
// Don't override if already set by another handler or the server
|
|
39
|
+
if (e.detail.selectOverride) return;
|
|
40
|
+
|
|
41
|
+
// Don't override if the element explicitly declares hx-select
|
|
42
|
+
if (elt.hasAttribute('hx-select')) return;
|
|
43
|
+
|
|
44
|
+
e.detail.selectOverride = 'unset';
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Boosted-nav interceptor + Layout mismatch detection
|
|
48
|
+
document.body.addEventListener('htmx:beforeSwap', (e) => {
|
|
49
|
+
const detail = e.detail || {};
|
|
50
|
+
const elt = detail.requestConfig?.elt;
|
|
51
|
+
|
|
52
|
+
if (!elt || !isBoostedPageNav(elt)) return;
|
|
53
|
+
|
|
54
|
+
const app = document.getElementById('app');
|
|
55
|
+
if (!app) return;
|
|
56
|
+
|
|
57
|
+
// --- Fix inherited target ---
|
|
58
|
+
if (detail.target !== app) {
|
|
59
|
+
const parser = new DOMParser();
|
|
60
|
+
const doc = parser.parseFromString(detail.serverResponse, 'text/html');
|
|
61
|
+
const newApp = doc.getElementById('app');
|
|
62
|
+
|
|
63
|
+
if (!newApp) return;
|
|
64
|
+
|
|
65
|
+
detail.target = app;
|
|
66
|
+
detail.serverResponse = newApp.outerHTML;
|
|
67
|
+
detail.swapOverride = 'outerHTML';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Strip admin opacity gate ---
|
|
71
|
+
// Admin layout uses style="opacity: 0" on #app to prevent content flash before
|
|
72
|
+
// auth check on full page load. On boosted nav auth is already verified, so strip
|
|
73
|
+
// it from the response before HTMX swaps it in.
|
|
74
|
+
if (detail.serverResponse) {
|
|
75
|
+
detail.serverResponse = detail.serverResponse.replace(
|
|
76
|
+
/(<[^>]*id=["']app["'][^>]*)\s*style=["']opacity:\s*0["']/,
|
|
77
|
+
'$1'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Layout mismatch detection ---
|
|
82
|
+
const currentLayout = app.dataset?.layout;
|
|
83
|
+
if (!currentLayout) return;
|
|
84
|
+
|
|
85
|
+
const incomingLayout = extractLayoutFromResponse(detail.serverResponse);
|
|
86
|
+
|
|
87
|
+
if (incomingLayout && incomingLayout !== currentLayout) {
|
|
88
|
+
detail.shouldSwap = false;
|
|
89
|
+
const path = detail.pathInfo?.requestPath || detail.xhr?.responseURL;
|
|
90
|
+
if (path) {
|
|
91
|
+
window.location.href = path;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
package/src/core/csrf.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ---------------------------
|
|
2
|
+
// CSRF Protection
|
|
3
|
+
// ---------------------------
|
|
4
|
+
// Self-registering module — import for side effects only.
|
|
5
|
+
// Must be imported BEFORE auth.js so the fetch interceptor captures
|
|
6
|
+
// the initial /api/auth/me request.
|
|
7
|
+
//
|
|
8
|
+
// Two layers:
|
|
9
|
+
// 1. Global fetch() interceptor — protects all existing fetch() calls
|
|
10
|
+
// 2. HTMX event hooks — protects all HTMX-driven requests
|
|
11
|
+
//
|
|
12
|
+
// Token is stored in a cookie (XSRF-TOKEN) set by the backend.
|
|
13
|
+
// All tabs share the same cookie, preventing multi-tab desync.
|
|
14
|
+
|
|
15
|
+
const CSRF_HEADER = 'X-CSRF-Token';
|
|
16
|
+
const CSRF_COOKIE = 'XSRF-TOKEN';
|
|
17
|
+
|
|
18
|
+
function readTokenFromCookie() {
|
|
19
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE}=([^;]*)`));
|
|
20
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getCsrfToken() {
|
|
24
|
+
return readTokenFromCookie();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function setCsrfToken(_token) {
|
|
28
|
+
// No-op — token is managed via Set-Cookie by the backend.
|
|
29
|
+
// Kept for API compatibility (fetch-client.js imports this).
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------
|
|
33
|
+
// Global fetch interceptor
|
|
34
|
+
// ---------------------------
|
|
35
|
+
// Patches window.fetch to inject CSRF header on /api/ requests.
|
|
36
|
+
|
|
37
|
+
const originalFetch = window.fetch.bind(window);
|
|
38
|
+
|
|
39
|
+
window.fetch = function (input, init = {}) {
|
|
40
|
+
const url = typeof input === 'string' ? input : input?.url || '';
|
|
41
|
+
|
|
42
|
+
if (url.startsWith('/api/')) {
|
|
43
|
+
const headers = new Headers(init.headers || {});
|
|
44
|
+
const token = readTokenFromCookie();
|
|
45
|
+
if (token) {
|
|
46
|
+
headers.set(CSRF_HEADER, token);
|
|
47
|
+
}
|
|
48
|
+
init = { ...init, headers };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return originalFetch(input, init);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------
|
|
55
|
+
// HTMX hooks
|
|
56
|
+
// ---------------------------
|
|
57
|
+
|
|
58
|
+
document.body.addEventListener('htmx:configRequest', (e) => {
|
|
59
|
+
const token = readTokenFromCookie();
|
|
60
|
+
if (token) {
|
|
61
|
+
e.detail.headers[CSRF_HEADER] = token;
|
|
62
|
+
}
|
|
63
|
+
});
|