@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.
Files changed (57) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +211 -0
  3. package/assets/bunnyland-api.js +345 -0
  4. package/assets/bunnyland-play.js +1223 -0
  5. package/assets/bunnyland-ui.css +1634 -0
  6. package/assets/bunnyland-ui.js +795 -0
  7. package/dist/admin-widgets.d.ts +31 -0
  8. package/dist/admin-widgets.d.ts.map +1 -0
  9. package/dist/admin-widgets.js +100 -0
  10. package/dist/admin-widgets.js.map +1 -0
  11. package/dist/api.d.ts +37 -0
  12. package/dist/api.d.ts.map +1 -0
  13. package/dist/api.js +125 -0
  14. package/dist/api.js.map +1 -0
  15. package/dist/index.d.ts +7 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/play.d.ts +265 -0
  19. package/dist/play.d.ts.map +1 -0
  20. package/dist/play.js +806 -0
  21. package/dist/play.js.map +1 -0
  22. package/dist/player-widgets.d.ts +11 -0
  23. package/dist/player-widgets.d.ts.map +1 -0
  24. package/dist/player-widgets.js +21 -0
  25. package/dist/player-widgets.js.map +1 -0
  26. package/dist/preact/auth.d.ts +37 -0
  27. package/dist/preact/auth.d.ts.map +1 -0
  28. package/dist/preact/components.d.ts +58 -0
  29. package/dist/preact/components.d.ts.map +1 -0
  30. package/dist/preact/theme.d.ts +16 -0
  31. package/dist/preact/theme.d.ts.map +1 -0
  32. package/dist/preact.d.ts +4 -0
  33. package/dist/preact.d.ts.map +1 -0
  34. package/dist/preact.js +488 -0
  35. package/dist/preact.js.map +1 -0
  36. package/dist/theme.d.ts +31 -0
  37. package/dist/theme.d.ts.map +1 -0
  38. package/dist/theme.js +165 -0
  39. package/dist/theme.js.map +1 -0
  40. package/dist/widgets.d.ts +7 -0
  41. package/dist/widgets.d.ts.map +1 -0
  42. package/dist/widgets.js +31 -0
  43. package/dist/widgets.js.map +1 -0
  44. package/docs/admin/custom-web-themes.md +112 -0
  45. package/docs/developer/design-language.md +116 -0
  46. package/package.json +101 -0
  47. package/src/admin-widgets.ts +189 -0
  48. package/src/api.ts +193 -0
  49. package/src/index.ts +6 -0
  50. package/src/play.ts +1151 -0
  51. package/src/player-widgets.ts +29 -0
  52. package/src/preact/auth.tsx +338 -0
  53. package/src/preact/components.tsx +249 -0
  54. package/src/preact/theme.tsx +72 -0
  55. package/src/preact.ts +3 -0
  56. package/src/theme.ts +225 -0
  57. package/src/widgets.ts +41 -0
@@ -0,0 +1,189 @@
1
+ import { escapeHtml, normalizeTags } from './widgets';
2
+
3
+ export interface SearchDropdownOption {
4
+ value: string;
5
+ label?: string;
6
+ }
7
+
8
+ export function tagEditorHtml(tags: unknown, options: {
9
+ hiddenClass?: string;
10
+ hiddenAttributes?: string;
11
+ disabled?: boolean;
12
+ addLabel?: string;
13
+ placeholder?: string;
14
+ } = {}): string {
15
+ const values = normalizeTags(tags);
16
+ const hiddenClass = options.hiddenClass || 'component-tags-value';
17
+ const hiddenAttributes = options.hiddenAttributes || '';
18
+ const disabled = options.disabled ? ' disabled' : '';
19
+ const addLabel = options.addLabel || 'Add Tag';
20
+ const placeholder = options.placeholder || 'add tag...';
21
+ return `
22
+ <div class="tag-editor">
23
+ <input class="${escapeHtml(hiddenClass)}" ${hiddenAttributes} type="hidden" value="${escapeHtml(JSON.stringify(values))}">
24
+ <div class="tag-list">
25
+ ${renderTagPills(values, disabled)}
26
+ </div>
27
+ <div class="tag-entry">
28
+ <input class="tag-input" type="text" placeholder="${escapeHtml(placeholder)}" spellcheck="false"${disabled}>
29
+ <button type="button" data-add-tag${disabled}>${escapeHtml(addLabel)}</button>
30
+ </div>
31
+ </div>
32
+ `;
33
+ }
34
+
35
+ export function readTagEditorTags(editor: Element, options: { hiddenSelector?: string } = {}): string[] {
36
+ const hidden = editor.querySelector<HTMLInputElement>(options.hiddenSelector || '.component-tags-value');
37
+ try {
38
+ return normalizeTags(JSON.parse(hidden?.value || '[]'));
39
+ } catch (_err) {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ export function renderTagEditorTags(editor: Element, tags: unknown): void {
45
+ const list = editor.querySelector('.tag-list');
46
+ if (!list) return;
47
+ list.innerHTML = renderTagPills(normalizeTags(tags), '');
48
+ }
49
+
50
+ export function bindTagEditor(editor: Element, options: {
51
+ hiddenSelector?: string;
52
+ onChange?: (tags: string[]) => void;
53
+ } = {}): { readTags: () => string[]; update: (tags: string[]) => void } {
54
+ const input = editor.querySelector<HTMLInputElement>('.tag-input');
55
+ const add = editor.querySelector('[data-add-tag]');
56
+ const hidden = editor.querySelector<HTMLInputElement>(options.hiddenSelector || '.component-tags-value');
57
+ const onChange = options.onChange || (() => {});
58
+ const update = (tags: string[]): void => {
59
+ const values = normalizeTags(tags);
60
+ if (hidden) hidden.value = JSON.stringify(values);
61
+ renderTagEditorTags(editor, values);
62
+ onChange(values);
63
+ };
64
+ const addTag = (): void => {
65
+ const tag = input?.value.trim();
66
+ if (!tag) return;
67
+ const tags = readTagEditorTags(editor, options);
68
+ if (!tags.includes(tag)) tags.push(tag);
69
+ if (input) input.value = '';
70
+ update(tags);
71
+ input?.focus();
72
+ };
73
+ add?.addEventListener('click', addTag);
74
+ input?.addEventListener('keydown', event => {
75
+ if (event.key !== 'Enter') return;
76
+ event.preventDefault();
77
+ addTag();
78
+ });
79
+ editor.addEventListener('click', event => {
80
+ const button = (event.target as Element | null)?.closest<HTMLElement>('[data-remove-tag]');
81
+ if (!button) return;
82
+ update(readTagEditorTags(editor, options).filter(tag => tag !== button.dataset.removeTag));
83
+ });
84
+ return { readTags: () => readTagEditorTags(editor, options), update };
85
+ }
86
+
87
+ export function bindSearchDropdown(root: Element, {
88
+ options,
89
+ value = '',
90
+ onChange = null,
91
+ emptyLabel = 'No matches',
92
+ }: {
93
+ options: Array<string | SearchDropdownOption>;
94
+ value?: string;
95
+ onChange?: ((value: string, item: SearchDropdownOption | null) => void) | null;
96
+ emptyLabel?: string;
97
+ }): { setValue: (value: string, notify?: boolean) => void } {
98
+ const input = root.querySelector<HTMLInputElement>('.search-dropdown-input');
99
+ const hidden = root.querySelector<HTMLInputElement>('.search-dropdown-value');
100
+ const menu = root.querySelector<HTMLElement>('.search-dropdown-menu');
101
+ if (!input || !hidden || !menu) throw new Error('Search dropdown is missing required elements');
102
+ const items = options.map(option => typeof option === 'string'
103
+ ? { value: option, label: option }
104
+ : { value: option.value, label: option.label || option.value });
105
+ let active = 0;
106
+ const filteredItems = (): SearchDropdownOption[] => {
107
+ const q = input.value.trim().toLowerCase();
108
+ if (!q) return items;
109
+ return items.filter(item =>
110
+ String(item.label).toLowerCase().includes(q) ||
111
+ item.value.toLowerCase().includes(q));
112
+ };
113
+ const setValue = (nextValue: string, notify = true): void => {
114
+ const item = items.find(option => option.value === nextValue) || null;
115
+ hidden.value = item?.value || '';
116
+ input.value = item?.label || '';
117
+ if (notify && onChange) onChange(hidden.value, item);
118
+ };
119
+ const renderMenu = (): void => {
120
+ const filtered = filteredItems();
121
+ active = Math.max(0, Math.min(active, filtered.length - 1));
122
+ menu.innerHTML = filtered.length
123
+ ? filtered.map((item, index) => `
124
+ <div class="search-dropdown-option ${index === active ? 'active' : ''}" data-value="${escapeHtml(item.value)}">
125
+ ${escapeHtml(item.label)}
126
+ </div>
127
+ `).join('')
128
+ : `<div class="search-dropdown-empty">${escapeHtml(emptyLabel)}</div>`;
129
+ menu.classList.remove('hidden');
130
+ input.setAttribute('aria-expanded', 'true');
131
+ };
132
+ const chooseActive = (): void => {
133
+ const item = filteredItems()[active];
134
+ if (!item) return;
135
+ setValue(item.value);
136
+ menu.classList.add('hidden');
137
+ input.setAttribute('aria-expanded', 'false');
138
+ };
139
+ input.addEventListener('input', () => {
140
+ active = 0;
141
+ renderMenu();
142
+ });
143
+ input.addEventListener('focus', renderMenu);
144
+ input.addEventListener('keydown', event => {
145
+ if (event.key === 'ArrowDown') {
146
+ event.preventDefault();
147
+ active += 1;
148
+ renderMenu();
149
+ } else if (event.key === 'ArrowUp') {
150
+ event.preventDefault();
151
+ active -= 1;
152
+ renderMenu();
153
+ } else if (event.key === 'Enter') {
154
+ event.preventDefault();
155
+ chooseActive();
156
+ } else if (event.key === 'Escape') {
157
+ menu.classList.add('hidden');
158
+ input.setAttribute('aria-expanded', 'false');
159
+ input.blur();
160
+ }
161
+ });
162
+ input.addEventListener('blur', () => {
163
+ setTimeout(() => {
164
+ menu.classList.add('hidden');
165
+ input.setAttribute('aria-expanded', 'false');
166
+ }, 150);
167
+ });
168
+ menu.addEventListener('mousedown', event => {
169
+ const option = (event.target as Element | null)?.closest<HTMLElement>('.search-dropdown-option');
170
+ if (!option) return;
171
+ event.preventDefault();
172
+ setValue(option.dataset.value || '');
173
+ menu.classList.add('hidden');
174
+ input.setAttribute('aria-expanded', 'false');
175
+ });
176
+ setValue(value, false);
177
+ return { setValue };
178
+ }
179
+
180
+ function renderTagPills(values: string[], disabled: string): string {
181
+ return values.length
182
+ ? values.map(tag => `
183
+ <span class="tag-pill">
184
+ <span>${escapeHtml(tag)}</span>
185
+ <button type="button" data-remove-tag="${escapeHtml(tag)}" aria-label="Remove tag ${escapeHtml(tag)}"${disabled}>x</button>
186
+ </span>
187
+ `).join('')
188
+ : '<span class="tiny">No tags.</span>';
189
+ }
package/src/api.ts ADDED
@@ -0,0 +1,193 @@
1
+ export interface AdminAuth {
2
+ authorization?: string;
3
+ clientId?: string;
4
+ }
5
+
6
+ export interface ControlClaimLike {
7
+ claimId?: string;
8
+ claimSecret?: string;
9
+ clientId?: string;
10
+ }
11
+
12
+ export class ApiError extends Error {
13
+ readonly status: number;
14
+
15
+ constructor(message: string, status: number) {
16
+ super(message);
17
+ this.name = 'ApiError';
18
+ this.status = status;
19
+ }
20
+ }
21
+
22
+ let playerAuthHeader = '';
23
+ const playerAuthListeners = new Set<() => void>();
24
+ let browserClientId = typeof globalThis.crypto?.randomUUID === 'function'
25
+ ? globalThis.crypto.randomUUID()
26
+ : `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
27
+
28
+ export function normalizeBase(url: string): string {
29
+ return String(url || '').trim().replace(/\/$/, '');
30
+ }
31
+
32
+ export function assertSameOriginBase(
33
+ base: string,
34
+ pageLocation = globalThis.location,
35
+ ): string {
36
+ const normalized = normalizeBase(base);
37
+ if (!pageLocation) return normalized;
38
+ const resolved = new URL(normalized || '/', pageLocation.href);
39
+ if (resolved.origin !== pageLocation.origin) {
40
+ throw new Error('Bunnyland browser connections must use the page origin');
41
+ }
42
+ return normalized;
43
+ }
44
+
45
+ export function serverFromUrl(search = globalThis.location?.search || ''): string {
46
+ const configured = new URLSearchParams(search).get('server') || '';
47
+ return configured ? assertSameOriginBase(configured) : '';
48
+ }
49
+
50
+ export function setServerInUrl(base: string, href = globalThis.location?.href || ''): void {
51
+ if (!href || !globalThis.history) return;
52
+ const url = new URL(href);
53
+ const normalized = assertSameOriginBase(base);
54
+ if (normalized) url.searchParams.set('server', normalized);
55
+ else url.searchParams.delete('server');
56
+ history.replaceState(null, '', url);
57
+ }
58
+
59
+ export function jsonHeaders(authHeader = ''): Record<string, string> {
60
+ return {
61
+ 'Content-Type': 'application/json',
62
+ 'X-Bunnyland-Client-Id': browserClientId,
63
+ ...(authHeader.startsWith('Bearer ') ? { Authorization: authHeader } : {}),
64
+ };
65
+ }
66
+
67
+ export function adminHeaders(auth: AdminAuth = {}, contentType = ''): Record<string, string> {
68
+ const headers: Record<string, string> = {
69
+ 'X-Bunnyland-Client-Id': auth.clientId || browserClientId,
70
+ };
71
+ if (contentType) headers['Content-Type'] = contentType;
72
+ if (auth.authorization?.startsWith('Bearer ')) headers.Authorization = auth.authorization;
73
+ return headers;
74
+ }
75
+
76
+ export function claimHeaders(control: ControlClaimLike | null = null): Record<string, string> {
77
+ return {
78
+ ...jsonHeaders(playerAuthHeader),
79
+ 'X-Bunnyland-Client-Id': control?.clientId || browserClientId,
80
+ ...(control?.claimSecret ? { 'X-Bunnyland-Claim-Secret': control.claimSecret } : {}),
81
+ };
82
+ }
83
+
84
+ export function mergePlayerHeaders(headers: HeadersInit = {}): Headers {
85
+ const merged = new Headers(headers);
86
+ if (!merged.has('X-Bunnyland-Client-Id')) {
87
+ merged.set('X-Bunnyland-Client-Id', browserClientId);
88
+ }
89
+ if (playerAuthHeader.startsWith('Bearer ')) {
90
+ merged.set('Authorization', playerAuthHeader);
91
+ }
92
+ return merged;
93
+ }
94
+
95
+ export async function parseJsonResponse(res: Response): Promise<unknown> {
96
+ const data = await res.json().catch(() => ({}));
97
+ if (!res.ok) {
98
+ const message = typeof data === 'object' && data && 'detail' in data ? String(data.detail) : `HTTP ${res.status}`;
99
+ throw new ApiError(message, res.status);
100
+ }
101
+ return data;
102
+ }
103
+
104
+ export async function sendJson(base: string, path: string, init: RequestInit = {}): Promise<unknown> {
105
+ const headers = mergePlayerHeaders(init.headers || {});
106
+ if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
107
+ return parseJsonResponse(await fetch(`${assertSameOriginBase(base)}${path}`, {
108
+ ...init,
109
+ headers,
110
+ credentials: 'include',
111
+ }));
112
+ }
113
+
114
+ export async function login(base: string, username: string, password: string): Promise<unknown> {
115
+ assertSameOriginBase(base);
116
+ return sendJson(base, '/auth/session', {
117
+ method: 'POST',
118
+ body: JSON.stringify({ username, password, delivery: 'cookie' }),
119
+ });
120
+ }
121
+
122
+ export async function authMe(base: string): Promise<unknown> {
123
+ return sendJson(base, '/auth/session');
124
+ }
125
+
126
+ export async function rotateAuth(base: string): Promise<unknown> {
127
+ return sendJson(base, '/auth/session', { method: 'PATCH' });
128
+ }
129
+
130
+ export async function logout(base: string): Promise<unknown> {
131
+ return sendJson(base, '/auth/session', { method: 'DELETE' });
132
+ }
133
+
134
+ export function setPlayerAuth(authHeader = ''): void {
135
+ const next = authHeader.startsWith('Bearer ') ? authHeader : '';
136
+ if (next === playerAuthHeader) return;
137
+ playerAuthHeader = next;
138
+ for (const listener of playerAuthListeners) listener();
139
+ }
140
+
141
+ export function getPlayerAuth(): string {
142
+ return playerAuthHeader;
143
+ }
144
+
145
+ export function subscribePlayerAuth(listener: () => void): () => void {
146
+ playerAuthListeners.add(listener);
147
+ return () => playerAuthListeners.delete(listener);
148
+ }
149
+
150
+ export function setClientId(clientId: string): void {
151
+ const normalized = clientId.trim();
152
+ if (normalized) browserClientId = normalized;
153
+ }
154
+
155
+ export function getClientId(): string {
156
+ return browserClientId;
157
+ }
158
+
159
+ export async function sendAdmin(base: string, path: string, auth: AdminAuth = {}, init: RequestInit = {}): Promise<unknown> {
160
+ return parseJsonResponse(await fetch(`${assertSameOriginBase(base)}${path}`, {
161
+ ...init,
162
+ headers: adminHeaders(auth),
163
+ credentials: 'include',
164
+ }));
165
+ }
166
+
167
+ export function socketUrl(base: string, path = '/admin/world/stream', _authHeader = ''): string {
168
+ const normalized = assertSameOriginBase(base);
169
+ const httpUrl = globalThis.location
170
+ ? new URL(`${normalized}${path}`, globalThis.location.href).href
171
+ : `${normalized}${path}`;
172
+ return httpUrl.replace(/^http/, 'ws');
173
+ }
174
+
175
+ export function mediaUrl(base: string, url: string): string {
176
+ if (!url) return '';
177
+ if (url.startsWith('data:')) return url;
178
+ if (/^https?:/.test(url)) {
179
+ assertSameOriginBase(url);
180
+ return url;
181
+ }
182
+ return `${assertSameOriginBase(base)}${url}`;
183
+ }
184
+
185
+ export async function requestSceneImage(base: string, characterId: string, control: ControlClaimLike | null = null): Promise<unknown> {
186
+ void characterId;
187
+ if (!control?.claimId) throw new Error('A character claim is required');
188
+ return sendJson(base, `/play/claims/${encodeURIComponent(control.claimId)}/jobs`, {
189
+ method: 'POST',
190
+ headers: claimHeaders(control),
191
+ body: JSON.stringify({ kind: 'scene_image' }),
192
+ });
193
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './api';
2
+ export * from './play';
3
+ export * from './theme';
4
+ export * from './widgets';
5
+ export * from './player-widgets';
6
+ export * from './admin-widgets';