@bunnyland/ui-web 0.2.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/LICENSE +661 -0
- package/README.md +211 -0
- package/assets/bunnyland-api.js +345 -0
- package/assets/bunnyland-play.js +1223 -0
- package/assets/bunnyland-ui.css +1634 -0
- package/assets/bunnyland-ui.js +795 -0
- package/dist/admin-widgets.d.ts +31 -0
- package/dist/admin-widgets.d.ts.map +1 -0
- package/dist/admin-widgets.js +100 -0
- package/dist/admin-widgets.js.map +1 -0
- package/dist/api.d.ts +37 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +125 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/play.d.ts +265 -0
- package/dist/play.d.ts.map +1 -0
- package/dist/play.js +806 -0
- package/dist/play.js.map +1 -0
- package/dist/player-widgets.d.ts +11 -0
- package/dist/player-widgets.d.ts.map +1 -0
- package/dist/player-widgets.js +21 -0
- package/dist/player-widgets.js.map +1 -0
- package/dist/preact/auth.d.ts +37 -0
- package/dist/preact/auth.d.ts.map +1 -0
- package/dist/preact/components.d.ts +58 -0
- package/dist/preact/components.d.ts.map +1 -0
- package/dist/preact/theme.d.ts +16 -0
- package/dist/preact/theme.d.ts.map +1 -0
- package/dist/preact.d.ts +4 -0
- package/dist/preact.d.ts.map +1 -0
- package/dist/preact.js +488 -0
- package/dist/preact.js.map +1 -0
- package/dist/theme.d.ts +31 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +165 -0
- package/dist/theme.js.map +1 -0
- package/dist/widgets.d.ts +7 -0
- package/dist/widgets.d.ts.map +1 -0
- package/dist/widgets.js +31 -0
- package/dist/widgets.js.map +1 -0
- package/docs/admin/custom-web-themes.md +112 -0
- package/docs/developer/design-language.md +116 -0
- package/package.json +101 -0
- package/src/admin-widgets.ts +189 -0
- package/src/api.ts +193 -0
- package/src/index.ts +6 -0
- package/src/play.ts +1151 -0
- package/src/player-widgets.ts +29 -0
- package/src/preact/auth.tsx +338 -0
- package/src/preact/components.tsx +249 -0
- package/src/preact/theme.tsx +72 -0
- package/src/preact.ts +3 -0
- package/src/theme.ts +225 -0
- package/src/widgets.ts +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Bunnyland UI Web
|
|
2
|
+
|
|
3
|
+
Shared Preact components, web UI assets, browser helpers, and gameplay display utilities for
|
|
4
|
+
Bunnyland browser clients. The package is publish-ready as `@bunnyland/ui-web` and keeps its
|
|
5
|
+
legacy browser globals intact while clients migrate incrementally.
|
|
6
|
+
|
|
7
|
+
## Static Browser Clients
|
|
8
|
+
|
|
9
|
+
Static clients can copy these assets into their served `assets/` directory:
|
|
10
|
+
|
|
11
|
+
- `assets/bunnyland-ui.css`
|
|
12
|
+
- `assets/bunnyland-ui.js`
|
|
13
|
+
- `assets/bunnyland-api.js`
|
|
14
|
+
- `assets/bunnyland-play.js`
|
|
15
|
+
|
|
16
|
+
The JavaScript assets preserve the browser globals used by the existing clients:
|
|
17
|
+
|
|
18
|
+
- `window.BunnylandUI`
|
|
19
|
+
- `window.BunnylandApi`
|
|
20
|
+
- `window.BunnylandPlay`
|
|
21
|
+
|
|
22
|
+
## Vite, TypeScript, And Preact Clients
|
|
23
|
+
|
|
24
|
+
Install the package and its deliberately small Preact peer dependency:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install @bunnyland/ui-web preact
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then import the typed helpers. Prefer narrow modules so Vite and Rollup can tree shake unrelated admin or player code:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { requestSceneImage } from '@bunnyland/ui-web/api';
|
|
34
|
+
import { filterActions } from '@bunnyland/ui-web/play';
|
|
35
|
+
import { bindThemeSelect } from '@bunnyland/ui-web/theme';
|
|
36
|
+
import { renderGalleryItems } from '@bunnyland/ui-web/player-widgets';
|
|
37
|
+
import '@bunnyland/ui-web/assets/bunnyland-ui.css';
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use direct Preact imports for shared functional components and hooks; React and
|
|
41
|
+
`preact/compat` are not required:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { render } from 'preact';
|
|
45
|
+
import { useState } from 'preact/hooks';
|
|
46
|
+
import {
|
|
47
|
+
Button,
|
|
48
|
+
Field,
|
|
49
|
+
Pane,
|
|
50
|
+
SearchSelect,
|
|
51
|
+
ThemeSelect,
|
|
52
|
+
Toolbar,
|
|
53
|
+
ToolbarRow,
|
|
54
|
+
useTheme,
|
|
55
|
+
} from '@bunnyland/ui-web/preact';
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`preact` is a peer dependency so each application owns one runtime and Vite can deduplicate
|
|
59
|
+
it. The library build externalizes `preact` and `preact/hooks` rather than bundling a second
|
|
60
|
+
copy. See the canonical [Bunnyland web design language](docs/developer/design-language.md)
|
|
61
|
+
for component boundaries, interaction states, and migration rules.
|
|
62
|
+
|
|
63
|
+
## Package Releases
|
|
64
|
+
|
|
65
|
+
Stable GitHub releases publish the matching package version to the public npm registry with
|
|
66
|
+
provenance. The release tag must exactly match `v<package.json version>`; for example,
|
|
67
|
+
package version `0.2.0` is published from tag `v0.2.0`. The dedicated `publish.yml` workflow
|
|
68
|
+
runs the complete package gate again before publishing.
|
|
69
|
+
|
|
70
|
+
The first publication needs a granular npm automation token with access to the `@bunnyland`
|
|
71
|
+
scope in the repository's `NPM_TOKEN` Actions secret. Once the package exists, configure its
|
|
72
|
+
npm trusted publisher with organization `thalismind`, repository `bunnyland-ui-web`, workflow
|
|
73
|
+
`publish.yml`, and the `npm publish` action. Then revoke the bootstrap token; the same
|
|
74
|
+
workflow uses GitHub Actions OIDC and automatically attaches npm provenance.
|
|
75
|
+
|
|
76
|
+
Consumers should pin an exact registry version:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm install --save-exact @bunnyland/ui-web@0.2.0 preact
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Versioned Package Artifact
|
|
83
|
+
|
|
84
|
+
Build a standalone npm tarball for local package inspection with:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm ci
|
|
88
|
+
npm run pack:artifact
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The output is `artifacts/bunnyland-ui-web-<version>.tgz`. It contains `package.json`, typed
|
|
92
|
+
`dist/` entry points, legacy `assets/`, source maps, and documentation, and has no dependency
|
|
93
|
+
on an adjacent checkout. CI uploads this tarball for every checked revision as a diagnostic
|
|
94
|
+
artifact. Release consumers use the exact published registry version, never an adjacent
|
|
95
|
+
source directory or a checked-in tarball.
|
|
96
|
+
|
|
97
|
+
## Themes
|
|
98
|
+
|
|
99
|
+
The package owns the shared `--bl-*` CSS variables and theme selector helpers. Built-in
|
|
100
|
+
palette names are:
|
|
101
|
+
|
|
102
|
+
- `purple-blue`
|
|
103
|
+
- `candy`
|
|
104
|
+
- `earth`
|
|
105
|
+
- `ocean`
|
|
106
|
+
- `sunset`
|
|
107
|
+
- `high-contrast`
|
|
108
|
+
|
|
109
|
+
Each palette follows `prefers-color-scheme` by default. The shared client menu can force
|
|
110
|
+
Dark or Light appearance, or return to Auto (System). Use `bindThemeSelect(select)` for
|
|
111
|
+
palette selectors, `bindColorSchemeSelect(select)` for appearance selectors, and
|
|
112
|
+
`setTheme(theme)` or `setColorScheme(scheme)` for direct changes. Old paired values such as
|
|
113
|
+
`anime-light` continue to load as migration aliases (`candy` with Light forced). New colors
|
|
114
|
+
should be added as CSS variables before clients depend on them.
|
|
115
|
+
|
|
116
|
+
Deployments can add their own theme choices without changing this package:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { registerThemeOptions } from '@bunnyland/ui-web/theme';
|
|
120
|
+
|
|
121
|
+
registerThemeOptions([
|
|
122
|
+
{ value: 'server-night', label: 'Server Night' },
|
|
123
|
+
]);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Then serve CSS for the matching root class:
|
|
127
|
+
|
|
128
|
+
```css
|
|
129
|
+
:root.bl-theme-server-night {
|
|
130
|
+
color-scheme: dark;
|
|
131
|
+
--bl-bg: #101218;
|
|
132
|
+
--bl-surface: #202532;
|
|
133
|
+
--bl-text: #f0f4ff;
|
|
134
|
+
--bl-accent: #74c7ec;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Static browser clients also read a `themes` array from deployment `config.json` and register
|
|
139
|
+
those options automatically. Theme values must use lowercase letters, numbers, and hyphens so
|
|
140
|
+
the value maps directly to `bl-theme-<value>`. Set `theme` in `config.json` to make one of
|
|
141
|
+
those values the deployment default, or share a link with `?theme=<value>` to choose a theme
|
|
142
|
+
from the URL.
|
|
143
|
+
|
|
144
|
+
See [custom web themes](docs/admin/custom-web-themes.md) for the server-admin setup guide.
|
|
145
|
+
|
|
146
|
+
## Widgets
|
|
147
|
+
|
|
148
|
+
The framework-neutral widget helpers are split by audience:
|
|
149
|
+
|
|
150
|
+
- `@bunnyland/ui-web/widgets`: common storage, escaping, and simple data helpers.
|
|
151
|
+
- `@bunnyland/ui-web/player-widgets`: contextual player controls such as photo galleries.
|
|
152
|
+
- `@bunnyland/ui-web/admin-widgets`: detailed admin/editor controls such as tag editors and search dropdowns.
|
|
153
|
+
|
|
154
|
+
Framework-specific UI packages can wrap these behaviors in components without duplicating the underlying logic. Use narrow imports instead of the root package when a client only needs one audience-specific surface.
|
|
155
|
+
|
|
156
|
+
Player clients should favor contextual, rich controls for interacting with the current room, visible entities, exits, inventory, queued actions, and images. Admin clients should favor detailed inspection and editing controls such as structured fields, sliders, selectors, tag editors, and lower-level component data. Keep those surfaces separate when adding reusable widgets.
|
|
157
|
+
|
|
158
|
+
## Player live updates and disclosed facts
|
|
159
|
+
|
|
160
|
+
`@bunnyland/ui-web/play` owns the shared remote-player update coordinator. It authenticates
|
|
161
|
+
the character WebSocket in the first frame so claim secrets never appear in URLs, coalesces
|
|
162
|
+
bursts into serialized refreshes, reconnects with backoff, and resumes the character-scoped
|
|
163
|
+
recent-event fallback while disconnected. It deduplicates at-least-once event delivery by
|
|
164
|
+
`event_id`, detects `stream_sequence` gaps, and refreshes the character projection after a
|
|
165
|
+
gap or `resync`. Use one coordinator per claimed character rather than giving each panel its
|
|
166
|
+
own socket or polling loop.
|
|
167
|
+
|
|
168
|
+
Player activity renderers understand serialized `facts` entries with stable `key`, `text`,
|
|
169
|
+
and numeric `detail`. Render the provided text in server order; do not reinterpret component
|
|
170
|
+
state or apply a second client-side cutoff. Action controls likewise consume the serialized
|
|
171
|
+
registry `actions` and `target_groups`. Missing metadata means an empty/disabled action state,
|
|
172
|
+
not a static browser verb catalogue.
|
|
173
|
+
|
|
174
|
+
## Storybook
|
|
175
|
+
|
|
176
|
+
A component storybook renders the shared Preact foundation, theme selector, form controls,
|
|
177
|
+
legacy widget helpers, and every semantic state as a live gallery. Build it with:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
npm run storybook
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That builds the package and writes `storybook-dist/`:
|
|
184
|
+
|
|
185
|
+
- `index.html` — the live, interactive storybook (theme selector, tag editor, search dropdown, client menu).
|
|
186
|
+
- `screenshots.html` — a gallery of one PNG per theme plus the client-menu overlay.
|
|
187
|
+
- `screenshots/*.png` — the captured screenshots.
|
|
188
|
+
- `storybook.zip` — an offline bundle of the HTML, CSS, JS, and screenshots.
|
|
189
|
+
|
|
190
|
+
The capture uses Playwright's bundled Chromium (`npx playwright install chromium`) and falls
|
|
191
|
+
back to a system Chrome via `STORYBOOK_BROWSER_CHANNEL` when the bundled browser is missing.
|
|
192
|
+
|
|
193
|
+
CI publishes the `storybook-dist/` directory and `storybook.zip` as build artifacts and builds
|
|
194
|
+
`Dockerfile.storybook` into an nginx image (`bunnyland-ui-web-storybook`) that serves the
|
|
195
|
+
browsable storybook.
|
|
196
|
+
|
|
197
|
+
## Checks
|
|
198
|
+
|
|
199
|
+
Run:
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
npm run check
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
That runs ESLint, the Vite 8 library build, TypeScript declaration generation, Node test
|
|
206
|
+
coverage, and the Preact component tests in a browser-like DOM.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
Licensed under the GNU Affero General Public License v3.0 or later. See
|
|
211
|
+
[LICENSE](LICENSE).
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
let playerAuthHeader = null;
|
|
5
|
+
let rotationTimer = null;
|
|
6
|
+
let browserClientId = typeof globalThis.crypto?.randomUUID === 'function'
|
|
7
|
+
? globalThis.crypto.randomUUID()
|
|
8
|
+
: `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
9
|
+
|
|
10
|
+
class ApiError extends Error {
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ApiError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeBase(url) {
|
|
19
|
+
return String(url || '').trim().replace(/\/$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertSameOriginBase(base) {
|
|
23
|
+
const normalized = normalizeBase(base);
|
|
24
|
+
const resolved = new URL(normalized || '/', location.href);
|
|
25
|
+
if (resolved.origin !== location.origin) {
|
|
26
|
+
throw new Error('Bunnyland browser connections must use the page origin');
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function serverFromUrl() {
|
|
32
|
+
const configured = new URLSearchParams(location.search).get('server') || '';
|
|
33
|
+
return configured ? assertSameOriginBase(configured) : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setServerInUrl(base) {
|
|
37
|
+
const url = new URL(location.href);
|
|
38
|
+
const normalized = assertSameOriginBase(base);
|
|
39
|
+
if (normalized) url.searchParams.set('server', normalized);
|
|
40
|
+
else url.searchParams.delete('server');
|
|
41
|
+
history.replaceState(null, '', url);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truthyConfig(value) {
|
|
45
|
+
return value === true || value === 'true' || value === '1';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergedJsonHeaders(headers = null) {
|
|
49
|
+
return {
|
|
50
|
+
...jsonHeaders(playerAuthHeader),
|
|
51
|
+
...(headers || {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scheduleRotation(base, status) {
|
|
56
|
+
if (rotationTimer) clearTimeout(rotationTimer);
|
|
57
|
+
if (!status?.rotate_after) return;
|
|
58
|
+
const delay = Math.max(0, (Number(status.rotate_after) * 1000) - Date.now());
|
|
59
|
+
rotationTimer = setTimeout(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const next = await rotateAuth(base);
|
|
62
|
+
scheduleRotation(base, next);
|
|
63
|
+
} catch (_error) {
|
|
64
|
+
rotationTimer = null;
|
|
65
|
+
}
|
|
66
|
+
}, Math.min(delay, 2147483647));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function authMe(base) {
|
|
70
|
+
const res = await fetch(`${assertSameOriginBase(base)}/auth/session`, {
|
|
71
|
+
credentials: 'include', headers: jsonHeaders(playerAuthHeader),
|
|
72
|
+
});
|
|
73
|
+
return parseJsonResponse(res);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function login(base, username, password) {
|
|
77
|
+
const res = await fetch(`${assertSameOriginBase(base)}/auth/session`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
credentials: 'include',
|
|
80
|
+
headers: jsonHeaders(playerAuthHeader),
|
|
81
|
+
body: JSON.stringify({ username, password, delivery: 'cookie' }),
|
|
82
|
+
});
|
|
83
|
+
const status = await parseJsonResponse(res);
|
|
84
|
+
scheduleRotation(base, status);
|
|
85
|
+
return status;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function rotateAuth(base) {
|
|
89
|
+
const res = await fetch(`${assertSameOriginBase(base)}/auth/session`, {
|
|
90
|
+
method: 'PATCH',
|
|
91
|
+
credentials: 'include',
|
|
92
|
+
headers: jsonHeaders(playerAuthHeader),
|
|
93
|
+
});
|
|
94
|
+
return parseJsonResponse(res);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function logout(base) {
|
|
98
|
+
const res = await fetch(`${assertSameOriginBase(base)}/auth/session`, {
|
|
99
|
+
method: 'DELETE',
|
|
100
|
+
credentials: 'include',
|
|
101
|
+
headers: jsonHeaders(playerAuthHeader),
|
|
102
|
+
});
|
|
103
|
+
if (rotationTimer) clearTimeout(rotationTimer);
|
|
104
|
+
rotationTimer = null;
|
|
105
|
+
return parseJsonResponse(res);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function ensurePlayerAuth(base) {
|
|
109
|
+
try {
|
|
110
|
+
const status = await authMe(base);
|
|
111
|
+
scheduleRotation(base, status);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (_error) {
|
|
114
|
+
return promptPlayerAuth(base);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function applyConfigToInput({ inputId = 'api-url', isConnected = () => false, connect = null } = {}) {
|
|
119
|
+
const config = await BunnylandUI.loadConfig();
|
|
120
|
+
const input = document.getElementById(inputId);
|
|
121
|
+
if (config.serverUrl && input && !isConnected()) input.value = config.serverUrl;
|
|
122
|
+
if (config.autoConnect && config.serverUrl && !isConnected() && connect) {
|
|
123
|
+
if (!truthyConfig(config.playerAuthRequired) || await ensurePlayerAuth(config.serverUrl)) {
|
|
124
|
+
connect(config.serverUrl);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return config;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyServerParam({ inputId = 'api-url', connect = null } = {}) {
|
|
131
|
+
const server = serverFromUrl();
|
|
132
|
+
if (!server) return '';
|
|
133
|
+
const input = document.getElementById(inputId);
|
|
134
|
+
if (input) input.value = server;
|
|
135
|
+
if (connect) connect(server);
|
|
136
|
+
return server;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function jsonHeaders(authHeader = null) {
|
|
140
|
+
return {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'X-Bunnyland-Client-Id': browserClientId,
|
|
143
|
+
...(String(authHeader || '').startsWith('Bearer ') ? { Authorization: authHeader } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function adminHeaders(authHeader = null, contentType = null) {
|
|
148
|
+
const headers = { 'X-Bunnyland-Client-Id': browserClientId };
|
|
149
|
+
if (contentType) headers['Content-Type'] = contentType;
|
|
150
|
+
if (String(authHeader || '').startsWith('Bearer ')) {
|
|
151
|
+
headers.Authorization = authHeader;
|
|
152
|
+
}
|
|
153
|
+
return headers;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function claimHeaders(control = null) {
|
|
157
|
+
return {
|
|
158
|
+
...jsonHeaders(playerAuthHeader),
|
|
159
|
+
'X-Bunnyland-Client-Id': control?.clientId || browserClientId,
|
|
160
|
+
...(control?.claimSecret ? { 'X-Bunnyland-Claim-Secret': control.claimSecret } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function parseJsonResponse(res) {
|
|
165
|
+
const data = await res.json().catch(() => ({}));
|
|
166
|
+
if (!res.ok) throw new ApiError(data.detail || `HTTP ${res.status}`, res.status);
|
|
167
|
+
return data;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function sendJson(base, path, { method = 'GET', body = null, headers = null, promptAuth = true } = {}) {
|
|
171
|
+
const { data } = await sendJsonWithResponse(base, path, { method, body, headers, promptAuth });
|
|
172
|
+
return data;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function sendJsonWithResponse(base, path, {
|
|
176
|
+
method = 'GET', body = null, headers = null, promptAuth = true,
|
|
177
|
+
} = {}) {
|
|
178
|
+
const currentHeaders = () => mergedJsonHeaders(headers);
|
|
179
|
+
let res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
180
|
+
method,
|
|
181
|
+
headers: currentHeaders(),
|
|
182
|
+
body,
|
|
183
|
+
credentials: 'include',
|
|
184
|
+
});
|
|
185
|
+
if (res.status === 401 && promptAuth) {
|
|
186
|
+
if (await promptPlayerAuth(base)) {
|
|
187
|
+
res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
188
|
+
method,
|
|
189
|
+
headers: currentHeaders(),
|
|
190
|
+
body,
|
|
191
|
+
credentials: 'include',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { data: await parseJsonResponse(res), response: res };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function promptPlayerAuth(base) {
|
|
199
|
+
const username = window.prompt('Bunnyland username');
|
|
200
|
+
if (!username) return null;
|
|
201
|
+
const password = window.prompt('Bunnyland password');
|
|
202
|
+
if (password == null) return null;
|
|
203
|
+
try {
|
|
204
|
+
await login(base, username, password);
|
|
205
|
+
return true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
window.alert(error.message || 'Login failed');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function setPlayerAuth(authHeader = null) {
|
|
213
|
+
playerAuthHeader = String(authHeader || '').startsWith('Bearer ') ? authHeader : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getPlayerAuth() {
|
|
217
|
+
return playerAuthHeader;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setClientId(clientId) {
|
|
221
|
+
const normalized = String(clientId || '').trim();
|
|
222
|
+
if (normalized) browserClientId = normalized;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getClientId() {
|
|
226
|
+
return browserClientId;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function sendAdmin(base, path, {
|
|
230
|
+
method = 'GET',
|
|
231
|
+
body = null,
|
|
232
|
+
prompt = true,
|
|
233
|
+
getAuth = () => null,
|
|
234
|
+
} = {}) {
|
|
235
|
+
const currentHeaders = () => jsonHeaders(getAuth());
|
|
236
|
+
let res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
237
|
+
method,
|
|
238
|
+
headers: currentHeaders(),
|
|
239
|
+
body,
|
|
240
|
+
credentials: 'include',
|
|
241
|
+
});
|
|
242
|
+
if (res.status === 401 && prompt) {
|
|
243
|
+
if (await promptPlayerAuth(base)) {
|
|
244
|
+
res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
245
|
+
method,
|
|
246
|
+
headers: currentHeaders(),
|
|
247
|
+
body,
|
|
248
|
+
credentials: 'include',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return parseJsonResponse(res);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function uploadCharacterImage(base, characterId, purpose, file, {
|
|
256
|
+
prompt = true,
|
|
257
|
+
getAuth = () => null,
|
|
258
|
+
} = {}) {
|
|
259
|
+
const path = `/admin/media/character/${encodeURIComponent(characterId)}/${encodeURIComponent(purpose)}`;
|
|
260
|
+
const form = new FormData();
|
|
261
|
+
form.append('file', file);
|
|
262
|
+
const currentHeaders = () => adminHeaders(getAuth());
|
|
263
|
+
let res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
264
|
+
method: 'PUT',
|
|
265
|
+
headers: currentHeaders(),
|
|
266
|
+
body: form,
|
|
267
|
+
credentials: 'include',
|
|
268
|
+
});
|
|
269
|
+
if (res.status === 401 && prompt) {
|
|
270
|
+
if (await promptPlayerAuth(base)) {
|
|
271
|
+
res = await fetch(`${assertSameOriginBase(base)}${path}`, {
|
|
272
|
+
method: 'PUT',
|
|
273
|
+
headers: currentHeaders(),
|
|
274
|
+
body: form,
|
|
275
|
+
credentials: 'include',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return parseJsonResponse(res);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function socketUrl(base, path = '/admin/world/stream', _authHeader = null) {
|
|
283
|
+
const normalized = assertSameOriginBase(base);
|
|
284
|
+
return new URL(`${normalized}${path}`, location.href).href.replace(/^http/, 'ws');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function mediaUrl(base, url) {
|
|
288
|
+
if (!url) return '';
|
|
289
|
+
if (url.startsWith('data:')) return url;
|
|
290
|
+
if (/^https?:\/\//.test(url)) {
|
|
291
|
+
assertSameOriginBase(url);
|
|
292
|
+
return url;
|
|
293
|
+
}
|
|
294
|
+
return `${assertSameOriginBase(base)}${url}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function requestSceneImage(base, characterId, control = null) {
|
|
298
|
+
void characterId;
|
|
299
|
+
if (!control?.claimId) throw new Error('A character claim is required');
|
|
300
|
+
return sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}/jobs`, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: claimHeaders(control),
|
|
303
|
+
body: JSON.stringify({ kind: 'scene_image' }),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function requestEventImage(base, recordId, extra = '') {
|
|
308
|
+
return sendJson(base, '/admin/world/generation-jobs', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: JSON.stringify({ kind: 'image', entity_id: recordId, purpose: 'event', extra }),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
window.BunnylandApi = {
|
|
315
|
+
ApiError,
|
|
316
|
+
applyConfigToInput,
|
|
317
|
+
applyServerParam,
|
|
318
|
+
adminHeaders,
|
|
319
|
+
assertSameOriginBase,
|
|
320
|
+
authMe,
|
|
321
|
+
claimHeaders,
|
|
322
|
+
ensurePlayerAuth,
|
|
323
|
+
getClientId,
|
|
324
|
+
getPlayerAuth,
|
|
325
|
+
jsonHeaders,
|
|
326
|
+
login,
|
|
327
|
+
logout,
|
|
328
|
+
mediaUrl,
|
|
329
|
+
normalizeBase,
|
|
330
|
+
parseJsonResponse,
|
|
331
|
+
promptPlayerAuth,
|
|
332
|
+
requestEventImage,
|
|
333
|
+
requestSceneImage,
|
|
334
|
+
sendAdmin,
|
|
335
|
+
sendJson,
|
|
336
|
+
sendJsonWithResponse,
|
|
337
|
+
serverFromUrl,
|
|
338
|
+
setServerInUrl,
|
|
339
|
+
setClientId,
|
|
340
|
+
setPlayerAuth,
|
|
341
|
+
socketUrl,
|
|
342
|
+
rotateAuth,
|
|
343
|
+
uploadCharacterImage,
|
|
344
|
+
};
|
|
345
|
+
}());
|