@bunnyland/ui-web 0.2.1 → 0.2.2
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/assets/bunnyland-api.js +10 -6
- package/assets/bunnyland-ui.css +113 -1
- package/assets/bunnyland-ui.js +164 -10
- package/package.json +1 -1
package/assets/bunnyland-api.js
CHANGED
|
@@ -196,15 +196,19 @@
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
async function promptPlayerAuth(base) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
const credentials = await window.BunnylandUI.credentialsDialog({
|
|
200
|
+
message: 'Enter your player credentials to continue.',
|
|
201
|
+
title: 'Sign in to Bunnyland',
|
|
202
|
+
});
|
|
203
|
+
if (!credentials) return null;
|
|
203
204
|
try {
|
|
204
|
-
await login(base, username, password);
|
|
205
|
+
await login(base, credentials.username, credentials.password);
|
|
205
206
|
return true;
|
|
206
207
|
} catch (error) {
|
|
207
|
-
window.
|
|
208
|
+
await window.BunnylandUI.alertDialog(error.message || 'Login failed', {
|
|
209
|
+
title: 'Sign in failed',
|
|
210
|
+
tone: 'danger',
|
|
211
|
+
});
|
|
208
212
|
return false;
|
|
209
213
|
}
|
|
210
214
|
}
|
package/assets/bunnyland-ui.css
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
--bl-font-mono: 'Courier New', monospace;
|
|
10
10
|
--bl-radius-sm: 3px;
|
|
11
11
|
--bl-radius-md: 4px;
|
|
12
|
+
--bl-radius-lg: 6px;
|
|
12
13
|
--bl-text-xs: 10px;
|
|
13
14
|
--bl-text-sm: 11px;
|
|
14
15
|
--bl-text-md: 12px;
|
|
16
|
+
--bl-text-xl: 18px;
|
|
15
17
|
|
|
16
18
|
--bl-bg: #1e1e2e;
|
|
17
19
|
--bl-bg-strong: #181825;
|
|
@@ -36,6 +38,8 @@
|
|
|
36
38
|
--bl-ok: #a6e3a1;
|
|
37
39
|
--bl-error: #f38ba8;
|
|
38
40
|
--bl-warn: #fab387;
|
|
41
|
+
--bl-danger: var(--bl-error);
|
|
42
|
+
--bl-warning: var(--bl-warn);
|
|
39
43
|
--bl-info: #74c7ec;
|
|
40
44
|
|
|
41
45
|
--bl-shadow-popover: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
@@ -662,10 +666,14 @@ input[type=file] {
|
|
|
662
666
|
}
|
|
663
667
|
|
|
664
668
|
input:focus, select:focus, textarea:focus {
|
|
665
|
-
outline: none;
|
|
666
669
|
border-color: var(--bl-accent);
|
|
667
670
|
}
|
|
668
671
|
|
|
672
|
+
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
|
673
|
+
outline: 2px solid var(--bl-accent);
|
|
674
|
+
outline-offset: 2px;
|
|
675
|
+
}
|
|
676
|
+
|
|
669
677
|
button {
|
|
670
678
|
padding: 3px 10px;
|
|
671
679
|
background: var(--bl-surface);
|
|
@@ -743,6 +751,74 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|
|
743
751
|
gap: 8px;
|
|
744
752
|
}
|
|
745
753
|
|
|
754
|
+
.bl-dialog {
|
|
755
|
+
width: min(460px, calc(100vw - 24px));
|
|
756
|
+
max-height: calc(100dvh - 24px);
|
|
757
|
+
overflow: hidden;
|
|
758
|
+
padding: 0;
|
|
759
|
+
border: 1px solid var(--bl-border-control);
|
|
760
|
+
border-radius: var(--bl-radius-md);
|
|
761
|
+
color: var(--bl-text);
|
|
762
|
+
background: var(--bl-bg-strong);
|
|
763
|
+
box-shadow: var(--bl-shadow-popover);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.bl-dialog::backdrop { background: var(--bl-overlay); }
|
|
767
|
+
|
|
768
|
+
.bl-dialog-form {
|
|
769
|
+
display: flex;
|
|
770
|
+
flex-direction: column;
|
|
771
|
+
max-height: calc(100dvh - 24px);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.bl-dialog-header,
|
|
775
|
+
.bl-dialog-body,
|
|
776
|
+
.bl-dialog-actions {
|
|
777
|
+
padding: 14px;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.bl-dialog-header {
|
|
781
|
+
border-bottom: 1px solid var(--bl-border);
|
|
782
|
+
background: var(--bl-bg);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.bl-dialog-header h2 {
|
|
786
|
+
margin: 0;
|
|
787
|
+
color: var(--bl-text);
|
|
788
|
+
font-size: var(--bl-text-xl);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.bl-dialog-body {
|
|
792
|
+
display: grid;
|
|
793
|
+
gap: 12px;
|
|
794
|
+
overflow-y: auto;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.bl-dialog-message {
|
|
798
|
+
margin: 0;
|
|
799
|
+
color: var(--bl-text-soft);
|
|
800
|
+
font-size: var(--bl-text-md);
|
|
801
|
+
line-height: 1.5;
|
|
802
|
+
overflow-wrap: anywhere;
|
|
803
|
+
white-space: pre-wrap;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.bl-dialog-field {
|
|
807
|
+
display: grid;
|
|
808
|
+
gap: 5px;
|
|
809
|
+
font-size: var(--bl-text-sm);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.bl-dialog-field input { width: 100%; }
|
|
813
|
+
|
|
814
|
+
.bl-dialog-actions {
|
|
815
|
+
display: flex;
|
|
816
|
+
justify-content: flex-end;
|
|
817
|
+
gap: 8px;
|
|
818
|
+
border-top: 1px solid var(--bl-border);
|
|
819
|
+
background: var(--bl-bg);
|
|
820
|
+
}
|
|
821
|
+
|
|
746
822
|
#toolbar {
|
|
747
823
|
display: flex;
|
|
748
824
|
flex-direction: column;
|
|
@@ -910,6 +986,19 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|
|
910
986
|
padding: 6px;
|
|
911
987
|
}
|
|
912
988
|
|
|
989
|
+
.client-menu-section-title {
|
|
990
|
+
padding: 10px 10px 3px;
|
|
991
|
+
color: var(--bl-accent-strong);
|
|
992
|
+
font-size: var(--bl-text-xs);
|
|
993
|
+
font-weight: bold;
|
|
994
|
+
letter-spacing: 0.5px;
|
|
995
|
+
text-transform: uppercase;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.client-menu-section-title:first-child {
|
|
999
|
+
padding-top: 4px;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
913
1002
|
.client-menu-item {
|
|
914
1003
|
display: grid;
|
|
915
1004
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
@@ -952,6 +1041,18 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|
|
952
1041
|
margin-left: 6px;
|
|
953
1042
|
}
|
|
954
1043
|
|
|
1044
|
+
.client-menu-recommended {
|
|
1045
|
+
display: inline-block;
|
|
1046
|
+
margin-left: 7px;
|
|
1047
|
+
padding: 1px 5px;
|
|
1048
|
+
border: 1px solid var(--bl-ok);
|
|
1049
|
+
border-radius: var(--bl-radius-sm);
|
|
1050
|
+
color: var(--bl-ok);
|
|
1051
|
+
font-size: var(--bl-text-xs);
|
|
1052
|
+
font-weight: normal;
|
|
1053
|
+
vertical-align: middle;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
955
1056
|
.client-menu-item-desc {
|
|
956
1057
|
color: var(--bl-text-soft);
|
|
957
1058
|
font-size: var(--bl-text-sm);
|
|
@@ -1589,6 +1690,17 @@ textarea {
|
|
|
1589
1690
|
min-height: 30px;
|
|
1590
1691
|
}
|
|
1591
1692
|
|
|
1693
|
+
:is(.bl-page-welcome, .bl-page-toon, .bl-page-character, .bl-page-web-tui, .bl-page-web-repl) {
|
|
1694
|
+
--bl-text-xs: 12px;
|
|
1695
|
+
--bl-text-sm: 14px;
|
|
1696
|
+
--bl-text-md: 16px;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
:is(.bl-page-welcome, .bl-page-toon, .bl-page-character, .bl-page-web-tui, .bl-page-web-repl)
|
|
1700
|
+
:is(button, input[type=file], input[type=text], input[type=number], select, .button-link) {
|
|
1701
|
+
min-height: 44px;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1592
1704
|
.client-menu-backdrop {
|
|
1593
1705
|
padding: 12px;
|
|
1594
1706
|
}
|
package/assets/bunnyland-ui.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
const boundThemeSelects = new Set();
|
|
35
35
|
const boundColorSchemeSelects = new Set();
|
|
36
36
|
const CLIENT_MENU_SEEN_KEY = 'bunnyland.clientMenu.seen';
|
|
37
|
+
let actionDialogQueue = Promise.resolve();
|
|
37
38
|
let clientMenuBaseUrl = '';
|
|
38
39
|
// Admin tools order: World Generator, World Graph, editor tools alphabetically, then miscellaneous tools.
|
|
39
40
|
const CLIENT_MENU_ITEMS = [
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
title: 'Welcome',
|
|
43
44
|
label: 'Start here',
|
|
44
45
|
description: 'Project overview, docs, admin notes, and client chooser.',
|
|
46
|
+
group: 'Bunnyland',
|
|
45
47
|
supportsServer: true,
|
|
46
48
|
},
|
|
47
49
|
{
|
|
@@ -49,21 +51,25 @@
|
|
|
49
51
|
title: 'Bunnyland.dev',
|
|
50
52
|
label: 'Website',
|
|
51
53
|
description: 'Project homepage, feature overview, guides, and public docs.',
|
|
54
|
+
group: 'Bunnyland',
|
|
52
55
|
supportsServer: false,
|
|
53
56
|
},
|
|
54
|
-
{
|
|
55
|
-
href: 'toon-client.html',
|
|
56
|
-
title: 'Toon Client',
|
|
57
|
-
label: 'Player room view',
|
|
58
|
-
description: 'Claim a character and play from the room-focused visual client.',
|
|
59
|
-
supportsFocus: true,
|
|
60
|
-
supportsServer: true,
|
|
61
|
-
},
|
|
62
57
|
{
|
|
63
58
|
href: 'web-tui.html',
|
|
64
59
|
title: 'Web TUI',
|
|
65
60
|
label: 'Player action menu',
|
|
66
61
|
description: 'Claim a character and play from the terminal TUI-style browser client.',
|
|
62
|
+
group: 'Player clients',
|
|
63
|
+
recommended: true,
|
|
64
|
+
supportsFocus: true,
|
|
65
|
+
supportsServer: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
href: 'toon-client.html',
|
|
69
|
+
title: 'Toon Client',
|
|
70
|
+
label: 'Player room view',
|
|
71
|
+
description: 'Claim a character and play from the room-focused visual client.',
|
|
72
|
+
group: 'Player clients',
|
|
67
73
|
supportsFocus: true,
|
|
68
74
|
supportsServer: true,
|
|
69
75
|
},
|
|
@@ -72,6 +78,7 @@
|
|
|
72
78
|
title: 'Web REPL',
|
|
73
79
|
label: 'Text-based play',
|
|
74
80
|
description: 'Claim a character and play with typed commands in the browser.',
|
|
81
|
+
group: 'Player clients',
|
|
75
82
|
supportsFocus: true,
|
|
76
83
|
supportsServer: true,
|
|
77
84
|
},
|
|
@@ -80,6 +87,7 @@
|
|
|
80
87
|
title: 'Character Profile',
|
|
81
88
|
label: 'Profile and chat',
|
|
82
89
|
description: 'Open a character profile, inspect live state, and chat in character.',
|
|
90
|
+
group: 'Player clients',
|
|
83
91
|
supportsFocus: true,
|
|
84
92
|
supportsServer: true,
|
|
85
93
|
},
|
|
@@ -88,6 +96,7 @@
|
|
|
88
96
|
title: 'World Generator',
|
|
89
97
|
label: 'Admin generator',
|
|
90
98
|
description: 'Generate or replace a live world using enabled server generators.',
|
|
99
|
+
group: 'Builder & admin',
|
|
91
100
|
supportsServer: true,
|
|
92
101
|
admin: true,
|
|
93
102
|
},
|
|
@@ -96,6 +105,7 @@
|
|
|
96
105
|
title: 'World Graph',
|
|
97
106
|
label: 'Graph editor',
|
|
98
107
|
description: 'Browse and extend the ECS world graph from a snapshot or live server.',
|
|
108
|
+
group: 'Builder & admin',
|
|
99
109
|
supportsServer: true,
|
|
100
110
|
admin: true,
|
|
101
111
|
},
|
|
@@ -104,6 +114,7 @@
|
|
|
104
114
|
title: 'Behavior Editor',
|
|
105
115
|
label: 'Behavior trees',
|
|
106
116
|
description: 'Author behavior-tree JSON for behavioral controllers and register it live.',
|
|
117
|
+
group: 'Builder & admin',
|
|
107
118
|
supportsServer: true,
|
|
108
119
|
admin: true,
|
|
109
120
|
},
|
|
@@ -112,6 +123,7 @@
|
|
|
112
123
|
title: 'Memory Editor',
|
|
113
124
|
label: 'Memory editor',
|
|
114
125
|
description: 'Inspect and edit character memory collections on a live server.',
|
|
126
|
+
group: 'Builder & admin',
|
|
115
127
|
supportsServer: true,
|
|
116
128
|
admin: true,
|
|
117
129
|
},
|
|
@@ -120,6 +132,7 @@
|
|
|
120
132
|
title: 'Script Editor',
|
|
121
133
|
label: 'Automation scripts',
|
|
122
134
|
description: 'Create and validate script JSON blocks against a snapshot.',
|
|
135
|
+
group: 'Builder & admin',
|
|
123
136
|
supportsServer: false,
|
|
124
137
|
admin: true,
|
|
125
138
|
},
|
|
@@ -128,6 +141,7 @@
|
|
|
128
141
|
title: 'World Editor',
|
|
129
142
|
label: 'Admin editor',
|
|
130
143
|
description: 'Edit entities, components, relationships, fragments, and live snapshots.',
|
|
144
|
+
group: 'Builder & admin',
|
|
131
145
|
supportsServer: true,
|
|
132
146
|
admin: true,
|
|
133
147
|
},
|
|
@@ -136,6 +150,7 @@
|
|
|
136
150
|
title: 'Event Stream',
|
|
137
151
|
label: 'Event viewer',
|
|
138
152
|
description: 'Watch the live world event feed with expandable records and entity references.',
|
|
153
|
+
group: 'Builder & admin',
|
|
139
154
|
supportsServer: true,
|
|
140
155
|
admin: true,
|
|
141
156
|
},
|
|
@@ -144,6 +159,7 @@
|
|
|
144
159
|
title: 'Trace Analyzer',
|
|
145
160
|
label: 'Trace inspection',
|
|
146
161
|
description: 'Inspect live Tempo traces or load JSON and JSONL trace artifacts.',
|
|
162
|
+
group: 'Builder & admin',
|
|
147
163
|
supportsServer: true,
|
|
148
164
|
admin: true,
|
|
149
165
|
},
|
|
@@ -476,6 +492,138 @@
|
|
|
476
492
|
return url.origin === location.origin ? '' : ' target="_blank" rel="noopener"';
|
|
477
493
|
}
|
|
478
494
|
|
|
495
|
+
function ensureActionDialog() {
|
|
496
|
+
let dialog = document.getElementById('bl-action-dialog');
|
|
497
|
+
if (dialog) return dialog;
|
|
498
|
+
dialog = document.createElement('dialog');
|
|
499
|
+
dialog.id = 'bl-action-dialog';
|
|
500
|
+
dialog.className = 'bl-dialog';
|
|
501
|
+
dialog.setAttribute('aria-labelledby', 'bl-action-dialog-title');
|
|
502
|
+
document.body.appendChild(dialog);
|
|
503
|
+
return dialog;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function actionDialog({
|
|
507
|
+
cancelLabel = 'Cancel',
|
|
508
|
+
cancellable = true,
|
|
509
|
+
confirmLabel = 'OK',
|
|
510
|
+
fields = [],
|
|
511
|
+
message = '',
|
|
512
|
+
title = 'Bunnyland',
|
|
513
|
+
tone = 'default',
|
|
514
|
+
} = {}) {
|
|
515
|
+
const open = () => new Promise((resolve) => {
|
|
516
|
+
const dialog = ensureActionDialog();
|
|
517
|
+
const previousFocus = document.activeElement;
|
|
518
|
+
dialog.innerHTML = `
|
|
519
|
+
<form class="bl-dialog-form" method="dialog">
|
|
520
|
+
<div class="bl-dialog-header"><h2 id="bl-action-dialog-title">${escapeHtml(title)}</h2></div>
|
|
521
|
+
<div class="bl-dialog-body">
|
|
522
|
+
${message ? `<p class="bl-dialog-message">${escapeHtml(message)}</p>` : ''}
|
|
523
|
+
${fields.map((field, index) => `
|
|
524
|
+
<label class="bl-dialog-field">
|
|
525
|
+
<span>${escapeHtml(field.label || field.name || `Value ${index + 1}`)}</span>
|
|
526
|
+
<input name="${escapeHtml(field.name || `field-${index}`)}"
|
|
527
|
+
type="${escapeHtml(field.type || 'text')}"
|
|
528
|
+
value="${escapeHtml(field.value || '')}"
|
|
529
|
+
${field.autocomplete ? `autocomplete="${escapeHtml(field.autocomplete)}"` : ''}
|
|
530
|
+
${field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : ''}
|
|
531
|
+
${field.required ? 'required' : ''}>
|
|
532
|
+
</label>
|
|
533
|
+
`).join('')}
|
|
534
|
+
</div>
|
|
535
|
+
<div class="bl-dialog-actions">
|
|
536
|
+
${cancellable ? `<button class="bl-dialog-cancel" type="button">${escapeHtml(cancelLabel)}</button>` : ''}
|
|
537
|
+
<button class="bl-dialog-confirm ${tone === 'danger' ? 'bl-button-danger' : 'bl-button-primary'}" type="submit">${escapeHtml(confirmLabel)}</button>
|
|
538
|
+
</div>
|
|
539
|
+
</form>
|
|
540
|
+
`;
|
|
541
|
+
const form = dialog.querySelector('.bl-dialog-form');
|
|
542
|
+
const cancel = dialog.querySelector('.bl-dialog-cancel');
|
|
543
|
+
let settled = false;
|
|
544
|
+
|
|
545
|
+
const finish = (value) => {
|
|
546
|
+
if (settled) return;
|
|
547
|
+
settled = true;
|
|
548
|
+
dialog.removeEventListener('cancel', onCancel);
|
|
549
|
+
dialog.removeEventListener('click', onBackdropClick);
|
|
550
|
+
if (dialog.open && typeof dialog.close === 'function') dialog.close();
|
|
551
|
+
else dialog.removeAttribute('open');
|
|
552
|
+
previousFocus?.focus?.();
|
|
553
|
+
resolve(value);
|
|
554
|
+
};
|
|
555
|
+
const onCancel = (event) => {
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
if (cancellable) finish(null);
|
|
558
|
+
};
|
|
559
|
+
const onBackdropClick = (event) => {
|
|
560
|
+
if (cancellable && event.target === dialog) finish(null);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
form.addEventListener('submit', (event) => {
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
const values = {};
|
|
566
|
+
for (const field of fields) {
|
|
567
|
+
const input = form.elements.namedItem(field.name);
|
|
568
|
+
values[field.name] = input?.value || '';
|
|
569
|
+
}
|
|
570
|
+
finish(values);
|
|
571
|
+
});
|
|
572
|
+
cancel?.addEventListener('click', () => finish(null));
|
|
573
|
+
dialog.addEventListener('cancel', onCancel);
|
|
574
|
+
dialog.addEventListener('click', onBackdropClick);
|
|
575
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
576
|
+
else dialog.setAttribute('open', '');
|
|
577
|
+
(dialog.querySelector('input') || dialog.querySelector('.bl-dialog-confirm'))?.focus();
|
|
578
|
+
});
|
|
579
|
+
const queued = actionDialogQueue.then(open, open);
|
|
580
|
+
actionDialogQueue = queued.then(() => undefined, () => undefined);
|
|
581
|
+
return queued;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function promptDialog(message, options = {}) {
|
|
585
|
+
const fieldName = 'value';
|
|
586
|
+
const values = await actionDialog({
|
|
587
|
+
...options,
|
|
588
|
+
fields: [{
|
|
589
|
+
autocomplete: options.autocomplete,
|
|
590
|
+
label: options.label || 'Value',
|
|
591
|
+
name: fieldName,
|
|
592
|
+
placeholder: options.placeholder,
|
|
593
|
+
required: options.required,
|
|
594
|
+
type: options.type || 'text',
|
|
595
|
+
value: options.value || '',
|
|
596
|
+
}],
|
|
597
|
+
message,
|
|
598
|
+
});
|
|
599
|
+
return values ? values[fieldName] : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function confirmDialog(message, options = {}) {
|
|
603
|
+
return Boolean(await actionDialog({
|
|
604
|
+
...options,
|
|
605
|
+
confirmLabel: options.confirmLabel || 'Confirm',
|
|
606
|
+
fields: [],
|
|
607
|
+
message,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function credentialsDialog(options = {}) {
|
|
612
|
+
return actionDialog({
|
|
613
|
+
...options,
|
|
614
|
+
confirmLabel: options.confirmLabel || 'Sign in',
|
|
615
|
+
fields: [
|
|
616
|
+
{ autocomplete: 'username', label: options.usernameLabel || 'Username', name: 'username', required: true },
|
|
617
|
+
{ autocomplete: 'current-password', label: options.passwordLabel || 'Password', name: 'password', required: true, type: 'password' },
|
|
618
|
+
],
|
|
619
|
+
title: options.title || 'Sign in to Bunnyland',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function alertDialog(message, options = {}) {
|
|
624
|
+
await actionDialog({ ...options, cancellable: false, fields: [], message });
|
|
625
|
+
}
|
|
626
|
+
|
|
479
627
|
function ensureClientMenu() {
|
|
480
628
|
let dialog = document.getElementById('client-menu-dialog');
|
|
481
629
|
if (dialog) return dialog;
|
|
@@ -504,12 +652,14 @@
|
|
|
504
652
|
<button class="client-menu-close" type="button" aria-label="Close client menu">x</button>
|
|
505
653
|
</div>
|
|
506
654
|
<div class="client-menu-list">
|
|
507
|
-
${CLIENT_MENU_ITEMS.map((item) => {
|
|
655
|
+
${CLIENT_MENU_ITEMS.map((item, index) => {
|
|
508
656
|
const active = item.href === current || (current === '' && item.href === 'index.html');
|
|
657
|
+
const previousGroup = CLIENT_MENU_ITEMS[index - 1]?.group;
|
|
509
658
|
return `
|
|
659
|
+
${item.group !== previousGroup ? `<div class="client-menu-section-title">${escapeHtml(item.group)}</div>` : ''}
|
|
510
660
|
<a class="client-menu-item ${active ? 'active' : ''}" href="${escapeHtml(clientHref(item))}"${clientTargetAttrs(item)}>
|
|
511
661
|
<span class="client-menu-item-main">
|
|
512
|
-
<span class="client-menu-item-title">${escapeHtml(item.title)}${item.admin ? '<span class="client-menu-admin-badge" title="Requires authentication" aria-label="Requires authentication">●</span>' : ''}</span>
|
|
662
|
+
<span class="client-menu-item-title">${escapeHtml(item.title)}${item.recommended ? '<span class="client-menu-recommended">Recommended</span>' : ''}${item.admin ? '<span class="client-menu-admin-badge" title="Requires authentication" aria-label="Requires authentication">●</span>' : ''}</span>
|
|
513
663
|
<span class="client-menu-item-desc">${escapeHtml(item.description)}</span>
|
|
514
664
|
</span>
|
|
515
665
|
<span class="client-menu-item-label">${escapeHtml(active ? 'Current' : item.label)}</span>
|
|
@@ -768,6 +918,7 @@
|
|
|
768
918
|
}
|
|
769
919
|
|
|
770
920
|
window.BunnylandUI = {
|
|
921
|
+
alertDialog,
|
|
771
922
|
bindColorSchemeSelect,
|
|
772
923
|
bindTagEditor,
|
|
773
924
|
bindSearchDropdown,
|
|
@@ -777,12 +928,15 @@
|
|
|
777
928
|
currentColorScheme,
|
|
778
929
|
escapeHtml,
|
|
779
930
|
currentTheme,
|
|
931
|
+
confirmDialog,
|
|
932
|
+
credentialsDialog,
|
|
780
933
|
initClientMenu,
|
|
781
934
|
initHelp,
|
|
782
935
|
initTheme,
|
|
783
936
|
loadConfig,
|
|
784
937
|
normalizeTags,
|
|
785
938
|
normalizeTheme,
|
|
939
|
+
promptDialog,
|
|
786
940
|
registerThemeOption,
|
|
787
941
|
registerThemeOptions,
|
|
788
942
|
renderTagEditorTags,
|