@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
package/src/theme.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { escapeHtml, storageGet, storageSet } from './widgets';
2
+
3
+ export const THEME_KEY = 'bunnyland.theme';
4
+ export const THEME_CLASS_PREFIX = 'bl-theme-';
5
+ export const COLOR_SCHEME_KEY = 'bunnyland.color-scheme';
6
+ export const COLOR_SCHEME_CLASS_PREFIX = 'bl-color-scheme-';
7
+ export const DEFAULT_THEME = 'purple-blue';
8
+ export const THEME_CHANGE_EVENT = 'bunnyland:themechange';
9
+
10
+ export interface ThemeOption {
11
+ value: string;
12
+ label: string;
13
+ }
14
+
15
+ export type ColorScheme = 'auto' | 'dark' | 'light';
16
+
17
+ export const DEFAULT_THEME_OPTIONS: ThemeOption[] = [
18
+ { value: 'purple-blue', label: 'Purple / Blue' },
19
+ { value: 'candy', label: 'Candy Pink / Cyan' },
20
+ { value: 'earth', label: 'Earth Green / Gold' },
21
+ { value: 'ocean', label: 'Ocean Teal / Coral' },
22
+ { value: 'sunset', label: 'Sunset Orange / Plum' },
23
+ { value: 'high-contrast', label: 'High Contrast' },
24
+ ];
25
+
26
+ export const THEME_OPTIONS: ThemeOption[] = DEFAULT_THEME_OPTIONS.map(option => ({ ...option }));
27
+
28
+ const THEME_ALIASES: Record<string, string> = {
29
+ anime: 'candy',
30
+ 'anime-dark': 'candy-dark',
31
+ 'anime-light': 'candy-light',
32
+ dark: 'purple-blue-dark',
33
+ light: 'purple-blue-light',
34
+ };
35
+
36
+ const COLOR_SCHEME_OPTIONS: ThemeOption[] = [
37
+ { value: 'auto', label: 'Auto (System)' },
38
+ { value: 'dark', label: 'Dark' },
39
+ { value: 'light', label: 'Light' },
40
+ ];
41
+
42
+ const THEME_VALUE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
43
+ const boundThemeSelects = new Set<HTMLSelectElement>();
44
+ const boundColorSchemeSelects = new Set<HTMLSelectElement>();
45
+
46
+ function parseThemeSelection(name: string | null | undefined): { scheme: ColorScheme | null; theme: string } {
47
+ const raw = THEME_ALIASES[String(name || DEFAULT_THEME).trim()] || String(name || DEFAULT_THEME).trim();
48
+ if (isKnownTheme(raw)) return { theme: raw, scheme: null };
49
+ const match = raw.match(/^(.*)-(dark|light)$/);
50
+ if (match && isKnownTheme(match[1])) return { theme: match[1], scheme: match[2] as ColorScheme };
51
+ return { theme: raw, scheme: null };
52
+ }
53
+
54
+ function normalizeThemeValue(name: string | null | undefined): string {
55
+ return parseThemeSelection(name).theme;
56
+ }
57
+
58
+ function isKnownTheme(name: string): boolean {
59
+ return THEME_OPTIONS.some(option => option.value === name);
60
+ }
61
+
62
+ function sanitizeThemeOption(option: ThemeOption): ThemeOption | null {
63
+ const value = String(option?.value || '').trim();
64
+ if (!THEME_VALUE_PATTERN.test(value)) return null;
65
+ const label = String(option?.label || value).trim() || value;
66
+ return { value, label };
67
+ }
68
+
69
+ function renderThemeSelect(select: HTMLSelectElement): void {
70
+ const theme = currentTheme();
71
+ select.innerHTML = themeOptions().map(option => `
72
+ <option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>
73
+ `).join('');
74
+ select.value = theme;
75
+ }
76
+
77
+ function refreshThemeSelects(): void {
78
+ for (const select of boundThemeSelects) renderThemeSelect(select);
79
+ }
80
+
81
+ function normalizeColorSchemeValue(name: string | null | undefined): ColorScheme {
82
+ return name === 'dark' || name === 'light' ? name : 'auto';
83
+ }
84
+
85
+ function renderColorSchemeSelect(select: HTMLSelectElement): void {
86
+ select.innerHTML = COLOR_SCHEME_OPTIONS.map(option => `
87
+ <option value="${option.value}">${option.label}</option>
88
+ `).join('');
89
+ select.value = currentColorScheme();
90
+ }
91
+
92
+ function refreshColorSchemeSelects(): void {
93
+ for (const select of boundColorSchemeSelects) renderColorSchemeSelect(select);
94
+ }
95
+
96
+ export function themeFromSearch(search = globalThis.location?.search || ''): string | null {
97
+ const requested = new URLSearchParams(search).get('theme');
98
+ if (!requested) return null;
99
+ const theme = normalizeThemeValue(requested);
100
+ return isKnownTheme(theme) ? theme : null;
101
+ }
102
+
103
+ export function normalizeTheme(name: string | null | undefined): string {
104
+ const theme = normalizeThemeValue(name);
105
+ return isKnownTheme(theme) ? theme : DEFAULT_THEME;
106
+ }
107
+
108
+ export function currentTheme(root: HTMLElement = document.documentElement): string {
109
+ return normalizeTheme(root.dataset.theme);
110
+ }
111
+
112
+ export function currentColorScheme(root: HTMLElement = document.documentElement): ColorScheme {
113
+ return normalizeColorSchemeValue(root.dataset.colorScheme);
114
+ }
115
+
116
+ function dispatchThemeChange(root: HTMLElement): void {
117
+ if (typeof root.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
118
+ root.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, {
119
+ detail: { colorScheme: currentColorScheme(root), theme: currentTheme(root) },
120
+ }));
121
+ }
122
+ }
123
+
124
+ function applyTheme(theme: string, root: HTMLElement, persist: boolean): string {
125
+ for (const className of [...root.classList]) {
126
+ if (className.startsWith(THEME_CLASS_PREFIX)) root.classList.remove(className);
127
+ }
128
+ root.classList.add(`${THEME_CLASS_PREFIX}${theme}`);
129
+ root.dataset.theme = theme;
130
+ if (persist) storageSet(THEME_KEY, theme);
131
+ refreshThemeSelects();
132
+ dispatchThemeChange(root);
133
+ return theme;
134
+ }
135
+
136
+ function applyColorScheme(scheme: ColorScheme, root: HTMLElement, persist: boolean): ColorScheme {
137
+ for (const className of [...root.classList]) {
138
+ if (className.startsWith(COLOR_SCHEME_CLASS_PREFIX)) root.classList.remove(className);
139
+ }
140
+ if (scheme !== 'auto') root.classList.add(`${COLOR_SCHEME_CLASS_PREFIX}${scheme}`);
141
+ root.dataset.colorScheme = scheme;
142
+ if (persist) storageSet(COLOR_SCHEME_KEY, scheme);
143
+ refreshColorSchemeSelects();
144
+ dispatchThemeChange(root);
145
+ return scheme;
146
+ }
147
+
148
+ export function setColorScheme(name: string, root: HTMLElement = document.documentElement): ColorScheme {
149
+ return applyColorScheme(normalizeColorSchemeValue(name), root, true);
150
+ }
151
+
152
+ export function setTheme(name: string, root: HTMLElement = document.documentElement): string {
153
+ const selection = parseThemeSelection(name);
154
+ if (selection.scheme) applyColorScheme(selection.scheme, root, true);
155
+ const theme = normalizeTheme(selection.theme);
156
+ return applyTheme(theme, root, true);
157
+ }
158
+
159
+ export function initTheme(
160
+ root: HTMLElement = document.documentElement,
161
+ defaultTheme = DEFAULT_THEME,
162
+ search = globalThis.location?.search || '',
163
+ ): string {
164
+ const linkedValue = new URLSearchParams(search).get('theme');
165
+ const linkedTheme = themeFromSearch(search);
166
+ const stored = storageGet(THEME_KEY);
167
+ const selection = parseThemeSelection((linkedTheme && linkedValue) || stored || defaultTheme || DEFAULT_THEME);
168
+ const storedScheme = storageGet(COLOR_SCHEME_KEY);
169
+ const scheme = selection.scheme || normalizeColorSchemeValue(storedScheme);
170
+ applyColorScheme(scheme, root, Boolean(selection.scheme || storedScheme));
171
+ const theme = normalizeTheme(selection.theme);
172
+ return applyTheme(theme, root, Boolean(linkedTheme || (stored && isKnownTheme(selection.theme))));
173
+ }
174
+
175
+ export function themeOptions(): ThemeOption[] {
176
+ return THEME_OPTIONS.map(option => ({ ...option }));
177
+ }
178
+
179
+ export function colorSchemeOptions(): ThemeOption[] {
180
+ return COLOR_SCHEME_OPTIONS.map(option => ({ ...option }));
181
+ }
182
+
183
+ export function registerThemeOption(option: ThemeOption): ThemeOption | null {
184
+ const theme = sanitizeThemeOption(option);
185
+ if (!theme) return null;
186
+ const index = THEME_OPTIONS.findIndex(existing => existing.value === theme.value);
187
+ if (index === -1) THEME_OPTIONS.push(theme);
188
+ else THEME_OPTIONS[index] = theme;
189
+ refreshThemeSelects();
190
+ return { ...theme };
191
+ }
192
+
193
+ export function registerThemeOptions(options: ThemeOption[] | null | undefined): ThemeOption[] {
194
+ if (!Array.isArray(options)) return [];
195
+ return options
196
+ .map(option => registerThemeOption(option))
197
+ .filter((option): option is ThemeOption => option !== null);
198
+ }
199
+
200
+ export function bindThemeSelect(select: HTMLSelectElement | null): { setValue: (value: string) => void } | null {
201
+ if (!select) return null;
202
+ boundThemeSelects.add(select);
203
+ renderThemeSelect(select);
204
+ select.value = initTheme();
205
+ select.addEventListener('change', () => setTheme(select.value));
206
+ return {
207
+ setValue(value: string): void {
208
+ select.value = normalizeTheme(value);
209
+ setTheme(select.value);
210
+ },
211
+ };
212
+ }
213
+
214
+ export function bindColorSchemeSelect(select: HTMLSelectElement | null): { setValue: (value: string) => void } | null {
215
+ if (!select) return null;
216
+ boundColorSchemeSelects.add(select);
217
+ renderColorSchemeSelect(select);
218
+ select.addEventListener('change', () => setColorScheme(select.value));
219
+ return {
220
+ setValue(value: string): void {
221
+ select.value = normalizeColorSchemeValue(value);
222
+ setColorScheme(select.value);
223
+ },
224
+ };
225
+ }
package/src/widgets.ts ADDED
@@ -0,0 +1,41 @@
1
+ export function escapeHtml(value: unknown): string {
2
+ return String(value)
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;');
7
+ }
8
+
9
+ export function cloneJson<T>(value: T): T {
10
+ return JSON.parse(JSON.stringify(value)) as T;
11
+ }
12
+
13
+ export function storageGet(key: string): string | null {
14
+ try {
15
+ return localStorage.getItem(key);
16
+ } catch (_err) {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function storageSet(key: string, value: string): void {
22
+ try {
23
+ localStorage.setItem(key, value);
24
+ } catch (_err) {
25
+ // Preferences are best-effort; UI should keep working without storage.
26
+ }
27
+ }
28
+
29
+ export function storageRemove(key: string): void {
30
+ try {
31
+ localStorage.removeItem(key);
32
+ } catch (_err) {
33
+ // Best-effort storage cleanup only.
34
+ }
35
+ }
36
+
37
+ export function normalizeTags(value: unknown): string[] {
38
+ if (Array.isArray(value)) return value.map(tag => String(tag)).filter(Boolean);
39
+ if (typeof value === 'string') return value.split(',').map(tag => tag.trim()).filter(Boolean);
40
+ return [];
41
+ }