@developer.krd/discord-dashboard 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/dist/index.cjs ADDED
@@ -0,0 +1,2495 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ DashboardDesigner: () => DashboardDesigner,
34
+ builtinTemplateRenderers: () => builtinTemplateRenderers,
35
+ createDashboard: () => createDashboard,
36
+ createDashboardDesigner: () => createDashboardDesigner,
37
+ createDiscordHelpers: () => createDiscordHelpers,
38
+ getBuiltinTemplateRenderer: () => getBuiltinTemplateRenderer
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/dashboard.ts
43
+ var import_compression = __toESM(require("compression"), 1);
44
+ var import_express = __toESM(require("express"), 1);
45
+ var import_express_session = __toESM(require("express-session"), 1);
46
+ var import_helmet = __toESM(require("helmet"), 1);
47
+ var import_node_http = require("http");
48
+ var import_node_crypto = require("crypto");
49
+
50
+ // src/discord-helpers.ts
51
+ var DISCORD_API = "https://discord.com/api/v10";
52
+ async function fetchDiscordWithBot(botToken, path) {
53
+ const response = await fetch(`${DISCORD_API}${path}`, {
54
+ headers: {
55
+ Authorization: `Bot ${botToken}`
56
+ }
57
+ });
58
+ if (!response.ok) {
59
+ return null;
60
+ }
61
+ return await response.json();
62
+ }
63
+ function createDiscordHelpers(botToken) {
64
+ return {
65
+ async getChannel(channelId) {
66
+ return await fetchDiscordWithBot(botToken, `/channels/${channelId}`);
67
+ },
68
+ async getGuildChannels(guildId) {
69
+ return await fetchDiscordWithBot(botToken, `/guilds/${guildId}/channels`) ?? [];
70
+ },
71
+ async searchGuildChannels(guildId, query, options) {
72
+ const channels = await fetchDiscordWithBot(botToken, `/guilds/${guildId}/channels`) ?? [];
73
+ const normalizedQuery = query.trim().toLowerCase();
74
+ const limit = Math.max(1, Math.min(options?.limit ?? 10, 50));
75
+ return channels.filter((channel) => {
76
+ if (options?.nsfw !== void 0 && Boolean(channel.nsfw) !== options.nsfw) {
77
+ return false;
78
+ }
79
+ if (options?.channelTypes && options.channelTypes.length > 0 && !options.channelTypes.includes(channel.type)) {
80
+ return false;
81
+ }
82
+ if (!normalizedQuery) {
83
+ return true;
84
+ }
85
+ return channel.name.toLowerCase().includes(normalizedQuery);
86
+ }).slice(0, limit);
87
+ },
88
+ async getRole(guildId, roleId) {
89
+ const roles = await fetchDiscordWithBot(botToken, `/guilds/${guildId}/roles`);
90
+ if (!roles) {
91
+ return null;
92
+ }
93
+ return roles.find((role) => role.id === roleId) ?? null;
94
+ },
95
+ async getGuildRoles(guildId) {
96
+ return await fetchDiscordWithBot(botToken, `/guilds/${guildId}/roles`) ?? [];
97
+ },
98
+ async searchGuildRoles(guildId, query, options) {
99
+ const roles = await fetchDiscordWithBot(botToken, `/guilds/${guildId}/roles`) ?? [];
100
+ const normalizedQuery = query.trim().toLowerCase();
101
+ const limit = Math.max(1, Math.min(options?.limit ?? 10, 50));
102
+ return roles.filter((role) => {
103
+ if (!options?.includeManaged && role.managed) {
104
+ return false;
105
+ }
106
+ if (!normalizedQuery) {
107
+ return true;
108
+ }
109
+ return role.name.toLowerCase().includes(normalizedQuery);
110
+ }).sort((a, b) => b.position - a.position).slice(0, limit);
111
+ },
112
+ async searchGuildMembers(guildId, query, options) {
113
+ const limit = Math.max(1, Math.min(options?.limit ?? 10, 1e3));
114
+ const params = new URLSearchParams({
115
+ query: query.trim(),
116
+ limit: String(limit)
117
+ });
118
+ return await fetchDiscordWithBot(botToken, `/guilds/${guildId}/members/search?${params.toString()}`) ?? [];
119
+ },
120
+ async getGuildMember(guildId, userId) {
121
+ return await fetchDiscordWithBot(botToken, `/guilds/${guildId}/members/${userId}`);
122
+ }
123
+ };
124
+ }
125
+
126
+ // src/templates.ts
127
+ var appCss = `
128
+ :root {
129
+ color-scheme: dark;
130
+ --bg: #13151a;
131
+ --rail: #1e1f24;
132
+ --content-bg: #2b2d31;
133
+ --panel: #313338;
134
+ --panel-2: #3a3d43;
135
+ --text: #eef2ff;
136
+ --muted: #b5bac1;
137
+ --primary: #5865f2;
138
+ --success: #20c997;
139
+ --warning: #f4c95d;
140
+ --danger: #ff6b6b;
141
+ --info: #4dabf7;
142
+ --border: rgba(255, 255, 255, 0.12);
143
+ }
144
+ * { box-sizing: border-box; }
145
+ body {
146
+ margin: 0;
147
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
148
+ background: var(--bg);
149
+ color: var(--text);
150
+ }
151
+ .layout {
152
+ display: flex;
153
+ min-height: 100vh;
154
+ }
155
+ .sidebar {
156
+ width: 76px;
157
+ background: var(--rail);
158
+ padding: 12px 0;
159
+ border-right: 1px solid var(--border);
160
+ }
161
+ .server-rail {
162
+ display: flex;
163
+ flex-direction: column;
164
+ align-items: center;
165
+ gap: 10px;
166
+ }
167
+ .server-item {
168
+ position: relative;
169
+ width: 48px;
170
+ height: 48px;
171
+ border-radius: 50%;
172
+ overflow: visible;
173
+ border: none;
174
+ padding: 0;
175
+ background: var(--panel);
176
+ color: #fff;
177
+ font-weight: 700;
178
+ display: grid;
179
+ place-items: center;
180
+ cursor: pointer;
181
+ transition: border-radius .15s ease, background .15s ease, transform .15s ease;
182
+ }
183
+ .server-item:hover { border-radius: 16px; background: #404249; }
184
+ .server-item.active {
185
+ border-radius: 16px;
186
+ background: var(--primary);
187
+ transform: scale(1.1);
188
+ }
189
+ .server-item-indicator {
190
+ position: absolute;
191
+ left: -9px;
192
+ width: 4px;
193
+ height: 20px;
194
+ border-radius: 999px;
195
+ background: #fff;
196
+ opacity: 0;
197
+ transform: scaleY(0.5);
198
+ transition: opacity .15s ease, transform .15s ease, height .15s ease;
199
+ }
200
+ .server-item.active .server-item-indicator {
201
+ opacity: 1;
202
+ transform: scaleY(1);
203
+ height: 28px;
204
+ }
205
+ .server-avatar {
206
+ width: 100%;
207
+ height: 100%;
208
+ object-fit: cover;
209
+ object-position: center;
210
+ display: block;
211
+ border-radius: inherit;
212
+ }
213
+ .server-fallback {
214
+ font-weight: 700;
215
+ font-size: 0.8rem;
216
+ }
217
+ .main-tabs {
218
+ display: flex;
219
+ gap: 8px;
220
+ margin-bottom: 14px;
221
+ }
222
+ .main-tab.active {
223
+ background: var(--primary);
224
+ border-color: transparent;
225
+ }
226
+ .server-status {
227
+ position: absolute;
228
+ right: -3px;
229
+ bottom: -3px;
230
+ width: 12px;
231
+ height: 12px;
232
+ border-radius: 999px;
233
+ border: 2px solid var(--rail);
234
+ background: #3ba55d;
235
+ z-index: 2;
236
+ }
237
+ .server-status.offline {
238
+ background: #747f8d;
239
+ }
240
+ .content {
241
+ flex: 1;
242
+ background: var(--content-bg);
243
+ min-width: 0;
244
+ }
245
+ .topbar {
246
+ display: grid;
247
+ grid-template-columns: 1fr auto 1fr;
248
+ align-items: center;
249
+ padding: 14px 20px;
250
+ border-bottom: 1px solid var(--border);
251
+ }
252
+ .brand {
253
+ font-size: 1rem;
254
+ font-weight: 700;
255
+ }
256
+ .center-title {
257
+ text-align: center;
258
+ font-weight: 700;
259
+ font-size: 1rem;
260
+ }
261
+ .topbar-right {
262
+ justify-self: end;
263
+ }
264
+ .container {
265
+ padding: 22px;
266
+ }
267
+ .grid {
268
+ display: grid;
269
+ gap: 16px;
270
+ }
271
+ .cards { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
272
+ .panel {
273
+ background: var(--panel);
274
+ border: 1px solid var(--border);
275
+ border-radius: 10px;
276
+ padding: 16px;
277
+ }
278
+ .title { color: var(--muted); font-size: 0.9rem; }
279
+ .value { font-size: 1.7rem; font-weight: 700; margin-top: 6px; }
280
+ .subtitle { margin-top: 8px; color: var(--muted); font-size: 0.88rem; }
281
+ .section-title { font-size: 1rem; margin: 20px 0 12px; color: #ffffff; }
282
+ .pill {
283
+ padding: 4px 9px;
284
+ border-radius: 999px;
285
+ font-size: 0.76rem;
286
+ border: 1px solid var(--border);
287
+ color: var(--muted);
288
+ }
289
+ button {
290
+ border: 1px solid var(--border);
291
+ background: var(--panel-2);
292
+ color: var(--text);
293
+ border-radius: 8px;
294
+ padding: 8px 12px;
295
+ cursor: pointer;
296
+ }
297
+ button.primary {
298
+ background: var(--primary);
299
+ border: none;
300
+ }
301
+ button.danger { background: #3a1e27; border-color: rgba(255,107,107,.45); }
302
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
303
+ .home-categories {
304
+ display: flex;
305
+ gap: 8px;
306
+ flex-wrap: wrap;
307
+ margin-bottom: 12px;
308
+ }
309
+ .home-category-btn.active {
310
+ background: var(--primary);
311
+ border-color: transparent;
312
+ }
313
+ .home-sections {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ gap: 12px;
317
+ margin-bottom: 12px;
318
+ }
319
+ .home-section-panel {
320
+ flex: 0 0 100%;
321
+ max-width: 100%;
322
+ }
323
+ .home-width-50 {
324
+ flex-basis: calc(50% - 6px);
325
+ max-width: calc(50% - 6px);
326
+ }
327
+ .home-width-33 {
328
+ flex-basis: calc(33.333333% - 8px);
329
+ max-width: calc(33.333333% - 8px);
330
+ }
331
+ .home-width-20 {
332
+ flex-basis: calc(20% - 9.6px);
333
+ max-width: calc(20% - 9.6px);
334
+ }
335
+ @media (max-width: 980px) {
336
+ .home-width-50,
337
+ .home-width-33,
338
+ .home-width-20 {
339
+ flex-basis: 100%;
340
+ max-width: 100%;
341
+ }
342
+ }
343
+ .home-fields { display: grid; gap: 10px; margin-top: 10px; }
344
+ .home-field { display: grid; gap: 6px; }
345
+ .home-field label { color: var(--muted); font-size: 0.84rem; }
346
+ .lookup-wrap { position: relative; }
347
+ .home-input,
348
+ .home-textarea,
349
+ .home-select {
350
+ width: 100%;
351
+ border: 1px solid var(--border);
352
+ background: var(--panel-2);
353
+ color: var(--text);
354
+ border-radius: 8px;
355
+ padding: 8px 10px;
356
+ }
357
+ .home-textarea { min-height: 92px; resize: vertical; }
358
+ .home-checkbox {
359
+ width: 18px;
360
+ height: 18px;
361
+ }
362
+ .home-field-row {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ }
367
+ .home-message {
368
+ margin-top: 8px;
369
+ color: var(--muted);
370
+ font-size: 0.84rem;
371
+ }
372
+ .lookup-results {
373
+ position: absolute;
374
+ left: 0;
375
+ right: 0;
376
+ top: calc(100% + 6px);
377
+ z-index: 20;
378
+ border: 1px solid var(--border);
379
+ background: var(--panel);
380
+ border-radius: 8px;
381
+ max-height: 220px;
382
+ overflow: auto;
383
+ display: none;
384
+ }
385
+ .lookup-item {
386
+ width: 100%;
387
+ border: none;
388
+ border-radius: 0;
389
+ text-align: left;
390
+ padding: 8px 10px;
391
+ background: transparent;
392
+ }
393
+ .lookup-item:hover {
394
+ background: var(--panel-2);
395
+ }
396
+ .lookup-selected {
397
+ margin-top: 6px;
398
+ font-size: 0.82rem;
399
+ color: var(--muted);
400
+ }
401
+ .kv { display: grid; gap: 8px; margin-top: 10px; }
402
+ .kv-item {
403
+ display: flex;
404
+ justify-content: space-between;
405
+ border: 1px solid var(--border);
406
+ border-radius: 8px;
407
+ padding: 8px 10px;
408
+ background: var(--panel-2);
409
+ }
410
+ .plugin-fields {
411
+ display: grid;
412
+ gap: 10px;
413
+ margin-top: 10px;
414
+ }
415
+ .plugin-field {
416
+ display: grid;
417
+ gap: 6px;
418
+ }
419
+ .plugin-field > label {
420
+ color: var(--muted);
421
+ font-size: 0.84rem;
422
+ }
423
+ .list-editor {
424
+ border: 1px solid var(--border);
425
+ border-radius: 8px;
426
+ background: var(--panel-2);
427
+ padding: 8px;
428
+ display: grid;
429
+ gap: 8px;
430
+ }
431
+ .list-items {
432
+ display: grid;
433
+ gap: 6px;
434
+ }
435
+ .list-item {
436
+ display: grid;
437
+ grid-template-columns: auto 1fr auto;
438
+ gap: 8px;
439
+ align-items: center;
440
+ border: 1px solid var(--border);
441
+ border-radius: 8px;
442
+ padding: 6px 8px;
443
+ background: var(--panel);
444
+ }
445
+ .list-item.dragging {
446
+ opacity: .6;
447
+ }
448
+ .drag-handle {
449
+ color: var(--muted);
450
+ user-select: none;
451
+ cursor: grab;
452
+ font-size: 0.9rem;
453
+ }
454
+ .list-input {
455
+ width: 100%;
456
+ border: none;
457
+ outline: none;
458
+ background: transparent;
459
+ color: var(--text);
460
+ }
461
+ .list-add {
462
+ justify-self: start;
463
+ }
464
+ .empty { color: var(--muted); font-size: 0.9rem; }
465
+ `;
466
+ function escapeHtml(value) {
467
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
468
+ }
469
+ function renderDashboardHtml(name, basePath, setupDesign) {
470
+ const safeName = escapeHtml(name);
471
+ const scriptData = JSON.stringify({ basePath, setupDesign: setupDesign ?? {} });
472
+ return `<!DOCTYPE html>
473
+ <html lang="en">
474
+ <head>
475
+ <meta charset="UTF-8" />
476
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
477
+ <title>${safeName}</title>
478
+ <style>${appCss}</style>
479
+ </head>
480
+ <body>
481
+ <div class="layout">
482
+ <aside class="sidebar">
483
+ <div id="serverRail" class="server-rail"></div>
484
+ </aside>
485
+
486
+ <main class="content">
487
+ <header class="topbar">
488
+ <div class="brand">${safeName}</div>
489
+ <div id="centerTitle" class="center-title">User Dashboard</div>
490
+ <div id="userMeta" class="pill topbar-right">Loading...</div>
491
+ </header>
492
+
493
+ <div class="container">
494
+ <div class="main-tabs">
495
+ <button id="tabHome" class="main-tab active">Home</button>
496
+ <button id="tabPlugins" class="main-tab">Plugins</button>
497
+ </div>
498
+
499
+ <section id="homeArea">
500
+ <div class="section-title">Home</div>
501
+ <section id="homeCategories" class="home-categories"></section>
502
+ <section id="homeSections" class="home-sections"></section>
503
+
504
+ <section id="overviewArea">
505
+ <div class="section-title">Dashboard Stats</div>
506
+ <section id="overviewCards" class="grid cards"></section>
507
+ </section>
508
+ </section>
509
+
510
+ <section id="pluginsArea" style="display:none;">
511
+ <div class="section-title">Plugins</div>
512
+ <section id="plugins" class="grid"></section>
513
+ </section>
514
+ </div>
515
+ </main>
516
+ </div>
517
+
518
+ <script>
519
+ const dashboardConfig = ${scriptData};
520
+ const state = {
521
+ session: null,
522
+ guilds: [],
523
+ selectedGuildId: null,
524
+ homeCategories: [],
525
+ selectedHomeCategoryId: null,
526
+ activeMainTab: "home"
527
+ };
528
+
529
+ const el = {
530
+ serverRail: document.getElementById("serverRail"),
531
+ userMeta: document.getElementById("userMeta"),
532
+ centerTitle: document.getElementById("centerTitle"),
533
+ tabHome: document.getElementById("tabHome"),
534
+ tabPlugins: document.getElementById("tabPlugins"),
535
+ homeArea: document.getElementById("homeArea"),
536
+ pluginsArea: document.getElementById("pluginsArea"),
537
+ homeCategories: document.getElementById("homeCategories"),
538
+ homeSections: document.getElementById("homeSections"),
539
+ overviewArea: document.getElementById("overviewArea"),
540
+ overviewCards: document.getElementById("overviewCards"),
541
+ plugins: document.getElementById("plugins")
542
+ };
543
+
544
+ const fetchJson = async (url, init) => {
545
+ const response = await fetch(url, init);
546
+ if (!response.ok) {
547
+ throw new Error(await response.text());
548
+ }
549
+ return response.json();
550
+ };
551
+
552
+ const buildApiUrl = (path) => {
553
+ if (!state.selectedGuildId) {
554
+ return dashboardConfig.basePath + path;
555
+ }
556
+
557
+ const separator = path.includes("?") ? "&" : "?";
558
+ return dashboardConfig.basePath + path + separator + "guildId=" + encodeURIComponent(state.selectedGuildId);
559
+ };
560
+
561
+ const escapeHtml = (value) => String(value)
562
+ .replaceAll("&", "&amp;")
563
+ .replaceAll("<", "&lt;")
564
+ .replaceAll(">", "&gt;")
565
+ .replaceAll('"', "&quot;")
566
+ .replaceAll("'", "&#039;");
567
+
568
+ const normalizeBoxWidth = (value) => {
569
+ const numeric = Number(value);
570
+ if (numeric === 50 || numeric === 33 || numeric === 20) {
571
+ return numeric;
572
+ }
573
+
574
+ return 100;
575
+ };
576
+
577
+ const applySetupDesign = () => {
578
+ const root = document.documentElement;
579
+ const design = dashboardConfig.setupDesign || {};
580
+ const mappings = {
581
+ bg: "--bg",
582
+ rail: "--rail",
583
+ contentBg: "--content-bg",
584
+ panel: "--panel",
585
+ panel2: "--panel-2",
586
+ text: "--text",
587
+ muted: "--muted",
588
+ primary: "--primary",
589
+ success: "--success",
590
+ warning: "--warning",
591
+ danger: "--danger",
592
+ info: "--info",
593
+ border: "--border"
594
+ };
595
+
596
+ Object.entries(mappings).forEach(([key, cssVar]) => {
597
+ const value = design[key];
598
+ if (typeof value === "string" && value.trim().length > 0) {
599
+ root.style.setProperty(cssVar, value.trim());
600
+ }
601
+ });
602
+ };
603
+
604
+ const makeButton = (action, pluginId, panelId, panelElement) => {
605
+ const button = document.createElement("button");
606
+ button.textContent = action.label;
607
+ button.className = action.variant === "primary" ? "primary" : action.variant === "danger" ? "danger" : "";
608
+ button.addEventListener("click", async () => {
609
+ button.disabled = true;
610
+ try {
611
+ let payload = {};
612
+ if (action.collectFields && panelElement) {
613
+ const values = {};
614
+ const inputs = panelElement.querySelectorAll("[data-plugin-field-id]");
615
+ inputs.forEach((inputEl) => {
616
+ const fieldId = inputEl.dataset.pluginFieldId;
617
+ const fieldType = inputEl.dataset.pluginFieldType || "text";
618
+ if (!fieldId) {
619
+ return;
620
+ }
621
+
622
+ values[fieldId] = toFieldValue({ type: fieldType }, inputEl);
623
+ });
624
+ payload = {
625
+ panelId,
626
+ values
627
+ };
628
+ }
629
+
630
+ const actionUrl = buildApiUrl("/api/plugins/" + encodeURIComponent(pluginId) + "/" + encodeURIComponent(action.id));
631
+ const result = await fetchJson(actionUrl, {
632
+ method: "POST",
633
+ headers: { "Content-Type": "application/json" },
634
+ body: JSON.stringify(payload)
635
+ });
636
+ if (result.message) {
637
+ alert(result.message);
638
+ }
639
+ if (result.refresh) {
640
+ await refreshContent();
641
+ }
642
+ } catch (error) {
643
+ alert(error instanceof Error ? error.message : "Action failed");
644
+ } finally {
645
+ button.disabled = false;
646
+ }
647
+ });
648
+ return button;
649
+ };
650
+
651
+ const renderCards = (cards) => {
652
+ if (!cards.length) {
653
+ el.overviewCards.innerHTML = '<div class="empty">No cards configured yet.</div>';
654
+ return;
655
+ }
656
+
657
+ el.overviewCards.innerHTML = cards.map((card) => {
658
+ const subtitle = card.subtitle ? '<div class="subtitle">' + escapeHtml(card.subtitle) + '</div>' : "";
659
+ return '<article class="panel">'
660
+ + '<div class="title">' + escapeHtml(card.title) + '</div>'
661
+ + '<div class="value">' + escapeHtml(card.value) + '</div>'
662
+ + subtitle
663
+ + '</article>';
664
+ }).join("");
665
+ };
666
+
667
+ const shortName = (name) => {
668
+ if (!name) return "?";
669
+ const parts = String(name).trim().split(/s+/).filter(Boolean);
670
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
671
+ return (parts[0][0] + parts[1][0]).toUpperCase();
672
+ };
673
+
674
+ const addAvatarOrFallback = (button, item) => {
675
+ if (!item.avatarUrl) {
676
+ const fallback = document.createElement("span");
677
+ fallback.className = "server-fallback";
678
+ fallback.textContent = item.short;
679
+ button.appendChild(fallback);
680
+ return;
681
+ }
682
+
683
+ const avatar = document.createElement("img");
684
+ avatar.className = "server-avatar";
685
+ avatar.src = item.avatarUrl;
686
+ avatar.alt = item.name;
687
+ avatar.addEventListener("error", () => {
688
+ avatar.remove();
689
+ const fallback = document.createElement("span");
690
+ fallback.className = "server-fallback";
691
+ fallback.textContent = item.short;
692
+ button.appendChild(fallback);
693
+ });
694
+ button.appendChild(avatar);
695
+ };
696
+
697
+ const renderServerRail = () => {
698
+ const items = [{ id: null, name: "User Dashboard", short: "ME", avatarUrl: state.session?.user?.avatarUrl ?? null, botInGuild: true }].concat(
699
+ state.guilds.map((guild) => ({
700
+ id: guild.id,
701
+ name: guild.name,
702
+ short: shortName(guild.name),
703
+ avatarUrl: guild.iconUrl ?? null,
704
+ botInGuild: guild.botInGuild !== false,
705
+ inviteUrl: guild.inviteUrl
706
+ }))
707
+ );
708
+
709
+ el.serverRail.innerHTML = "";
710
+ items.forEach((item) => {
711
+ const button = document.createElement("button");
712
+ button.className = "server-item" + (item.id === state.selectedGuildId ? " active" : "");
713
+ button.title = item.id && !item.botInGuild ? (item.name + " \u2022 Invite bot") : item.name;
714
+
715
+ const activeIndicator = document.createElement("span");
716
+ activeIndicator.className = "server-item-indicator";
717
+ button.appendChild(activeIndicator);
718
+
719
+ addAvatarOrFallback(button, item);
720
+
721
+ if (item.id) {
722
+ const status = document.createElement("span");
723
+ status.className = "server-status" + (item.botInGuild ? "" : " offline");
724
+ button.appendChild(status);
725
+ }
726
+
727
+ button.addEventListener("click", async () => {
728
+ if (item.id && !item.botInGuild && item.inviteUrl) {
729
+ const opened = window.open(item.inviteUrl, "_blank", "noopener,noreferrer");
730
+ if (!opened && typeof alert === "function") {
731
+ alert("Popup blocked. Please allow popups to open the invite page.");
732
+ }
733
+ return;
734
+ }
735
+
736
+ state.selectedGuildId = item.id;
737
+ renderServerRail();
738
+ updateContextLabel();
739
+ await refreshContent();
740
+ });
741
+ el.serverRail.appendChild(button);
742
+ });
743
+ };
744
+
745
+ const applyMainTab = () => {
746
+ const homeActive = state.activeMainTab === "home";
747
+ el.homeArea.style.display = homeActive ? "block" : "none";
748
+ el.pluginsArea.style.display = homeActive ? "none" : "block";
749
+ el.tabHome.className = "main-tab" + (homeActive ? " active" : "");
750
+ el.tabPlugins.className = "main-tab" + (!homeActive ? " active" : "");
751
+ };
752
+
753
+ const updateContextLabel = () => {
754
+ if (!state.selectedGuildId) {
755
+ el.centerTitle.textContent = "User Dashboard";
756
+ return;
757
+ }
758
+
759
+ const selectedGuild = state.guilds.find((guild) => guild.id === state.selectedGuildId);
760
+ el.centerTitle.textContent = selectedGuild ? (selectedGuild.name + " Dashboard") : "Server Dashboard";
761
+ };
762
+
763
+ const renderHomeCategories = () => {
764
+ if (!state.homeCategories.length) {
765
+ el.homeCategories.innerHTML = "";
766
+ return;
767
+ }
768
+
769
+ el.homeCategories.innerHTML = "";
770
+ state.homeCategories.forEach((category) => {
771
+ const button = document.createElement("button");
772
+ button.className = "home-category-btn" + (state.selectedHomeCategoryId === category.id ? " active" : "");
773
+ button.textContent = category.label;
774
+ button.title = category.description || category.label;
775
+ button.addEventListener("click", async () => {
776
+ state.selectedHomeCategoryId = category.id;
777
+ renderHomeCategories();
778
+ await refreshContent();
779
+ });
780
+ el.homeCategories.appendChild(button);
781
+ });
782
+ };
783
+
784
+ const toFieldValue = (field, element) => {
785
+ if (field.type === "string-list") {
786
+ const raw = element.dataset.listValues;
787
+ if (!raw) {
788
+ return [];
789
+ }
790
+
791
+ try {
792
+ const parsed = JSON.parse(raw);
793
+ return Array.isArray(parsed) ? parsed : [];
794
+ } catch {
795
+ return [];
796
+ }
797
+ }
798
+
799
+ if (field.type === "role-search" || field.type === "channel-search" || field.type === "member-search") {
800
+ const raw = element.dataset.selectedObject;
801
+ if (!raw) {
802
+ return null;
803
+ }
804
+
805
+ try {
806
+ return JSON.parse(raw);
807
+ } catch {
808
+ return null;
809
+ }
810
+ }
811
+
812
+ if (field.type === "boolean") {
813
+ return Boolean(element.checked);
814
+ }
815
+
816
+ if (field.type === "number") {
817
+ if (element.value === "") {
818
+ return null;
819
+ }
820
+ return Number(element.value);
821
+ }
822
+
823
+ return element.value;
824
+ };
825
+
826
+ const setupStringListField = (field, input, fieldWrap) => {
827
+ const editor = document.createElement("div");
828
+ editor.className = "list-editor";
829
+ const itemsWrap = document.createElement("div");
830
+ itemsWrap.className = "list-items";
831
+ const addButton = document.createElement("button");
832
+ addButton.type = "button";
833
+ addButton.className = "list-add";
834
+ addButton.textContent = "Add Button";
835
+
836
+ const normalizeValues = () => {
837
+ const values = Array.from(itemsWrap.querySelectorAll(".list-input"))
838
+ .map((item) => item.value.trim())
839
+ .filter((item) => item.length > 0);
840
+ input.dataset.listValues = JSON.stringify(values);
841
+ };
842
+
843
+ const makeRow = (value = "") => {
844
+ const row = document.createElement("div");
845
+ row.className = "list-item";
846
+ row.draggable = true;
847
+
848
+ const handle = document.createElement("span");
849
+ handle.className = "drag-handle";
850
+ handle.textContent = "\u22EE\u22EE";
851
+
852
+ const textInput = document.createElement("input");
853
+ textInput.type = "text";
854
+ textInput.className = "list-input";
855
+ textInput.value = value;
856
+ textInput.placeholder = "Button label";
857
+ textInput.addEventListener("input", normalizeValues);
858
+
859
+ const removeButton = document.createElement("button");
860
+ removeButton.type = "button";
861
+ removeButton.textContent = "\xD7";
862
+ removeButton.addEventListener("click", () => {
863
+ row.remove();
864
+ normalizeValues();
865
+ });
866
+
867
+ row.addEventListener("dragstart", () => {
868
+ row.classList.add("dragging");
869
+ });
870
+ row.addEventListener("dragend", () => {
871
+ row.classList.remove("dragging");
872
+ normalizeValues();
873
+ });
874
+
875
+ row.appendChild(handle);
876
+ row.appendChild(textInput);
877
+ row.appendChild(removeButton);
878
+ return row;
879
+ };
880
+
881
+ itemsWrap.addEventListener("dragover", (event) => {
882
+ event.preventDefault();
883
+ const dragging = itemsWrap.querySelector(".dragging");
884
+ if (!dragging) {
885
+ return;
886
+ }
887
+
888
+ const siblings = Array.from(itemsWrap.querySelectorAll(".list-item:not(.dragging)"));
889
+ let inserted = false;
890
+
891
+ for (const sibling of siblings) {
892
+ const rect = sibling.getBoundingClientRect();
893
+ if (event.clientY < rect.top + rect.height / 2) {
894
+ itemsWrap.insertBefore(dragging, sibling);
895
+ inserted = true;
896
+ break;
897
+ }
898
+ }
899
+
900
+ if (!inserted) {
901
+ itemsWrap.appendChild(dragging);
902
+ }
903
+ });
904
+
905
+ const initialValues = Array.isArray(field.value)
906
+ ? field.value.map((item) => String(item))
907
+ : [];
908
+ if (initialValues.length === 0) {
909
+ initialValues.push("Yes", "No");
910
+ }
911
+
912
+ initialValues.forEach((value) => {
913
+ itemsWrap.appendChild(makeRow(value));
914
+ });
915
+
916
+ addButton.addEventListener("click", () => {
917
+ itemsWrap.appendChild(makeRow(""));
918
+ normalizeValues();
919
+ });
920
+
921
+ editor.appendChild(itemsWrap);
922
+ editor.appendChild(addButton);
923
+ fieldWrap.appendChild(editor);
924
+ normalizeValues();
925
+ };
926
+
927
+ const showLookupResults = (container, items, labelResolver, onSelect) => {
928
+ container.innerHTML = "";
929
+
930
+ if (!items.length) {
931
+ container.style.display = "none";
932
+ return;
933
+ }
934
+
935
+ items.forEach((item) => {
936
+ const btn = document.createElement("button");
937
+ btn.type = "button";
938
+ btn.className = "lookup-item";
939
+ btn.textContent = labelResolver(item);
940
+ btn.addEventListener("click", () => {
941
+ onSelect(item);
942
+ container.style.display = "none";
943
+ });
944
+ container.appendChild(btn);
945
+ });
946
+
947
+ container.style.display = "block";
948
+ };
949
+
950
+ const setupLookupField = (field, input, fieldWrap) => {
951
+ const wrap = document.createElement("div");
952
+ wrap.className = "lookup-wrap";
953
+ const results = document.createElement("div");
954
+ results.className = "lookup-results";
955
+ const selected = document.createElement("div");
956
+ selected.className = "lookup-selected";
957
+ selected.textContent = "No selection";
958
+
959
+ wrap.appendChild(input);
960
+ wrap.appendChild(results);
961
+ fieldWrap.appendChild(wrap);
962
+ fieldWrap.appendChild(selected);
963
+
964
+ const minQueryLength = Math.max(0, field.lookup?.minQueryLength ?? 1);
965
+ const limit = Math.max(1, Math.min(field.lookup?.limit ?? 10, 50));
966
+
967
+ const runSearch = async () => {
968
+ const query = String(input.value || "");
969
+ if (query.length < minQueryLength) {
970
+ results.style.display = "none";
971
+ return;
972
+ }
973
+
974
+ try {
975
+ if (field.type === "role-search") {
976
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
977
+ if (field.lookup?.includeManaged !== undefined) {
978
+ params.set("includeManaged", String(Boolean(field.lookup.includeManaged)));
979
+ }
980
+
981
+ const payload = await fetchJson(buildApiUrl("/api/lookup/roles?" + params.toString()));
982
+ showLookupResults(
983
+ results,
984
+ payload.roles || [],
985
+ (item) => "@" + item.name,
986
+ (item) => {
987
+ input.value = item.name;
988
+ input.dataset.selectedObject = JSON.stringify(item);
989
+ selected.textContent = "Selected role: @" + item.name + " (" + item.id + ")";
990
+ }
991
+ );
992
+ } else if (field.type === "channel-search") {
993
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
994
+ if (field.lookup?.nsfw !== undefined) {
995
+ params.set("nsfw", String(Boolean(field.lookup.nsfw)));
996
+ }
997
+ if (field.lookup?.channelTypes && field.lookup.channelTypes.length > 0) {
998
+ params.set("channelTypes", field.lookup.channelTypes.join(","));
999
+ }
1000
+
1001
+ const payload = await fetchJson(buildApiUrl("/api/lookup/channels?" + params.toString()));
1002
+ showLookupResults(
1003
+ results,
1004
+ payload.channels || [],
1005
+ (item) => "#" + item.name,
1006
+ (item) => {
1007
+ input.value = item.name;
1008
+ input.dataset.selectedObject = JSON.stringify(item);
1009
+ selected.textContent = "Selected channel: #" + item.name + " (" + item.id + ")";
1010
+ }
1011
+ );
1012
+ } else if (field.type === "member-search") {
1013
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
1014
+ const payload = await fetchJson(buildApiUrl("/api/lookup/members?" + params.toString()));
1015
+ showLookupResults(
1016
+ results,
1017
+ payload.members || [],
1018
+ (item) => {
1019
+ const username = item?.user?.username || "unknown";
1020
+ const nick = item?.nick ? " (" + item.nick + ")" : "";
1021
+ return username + nick;
1022
+ },
1023
+ (item) => {
1024
+ const username = item?.user?.username || "unknown";
1025
+ input.value = username;
1026
+ input.dataset.selectedObject = JSON.stringify(item);
1027
+ selected.textContent = "Selected member: " + username + " (" + (item?.user?.id || "unknown") + ")";
1028
+ }
1029
+ );
1030
+ }
1031
+ } catch {
1032
+ results.style.display = "none";
1033
+ }
1034
+ };
1035
+
1036
+ input.addEventListener("input", () => {
1037
+ input.dataset.selectedObject = "";
1038
+ selected.textContent = "No selection";
1039
+ runSearch();
1040
+ });
1041
+
1042
+ input.addEventListener("blur", () => {
1043
+ setTimeout(() => {
1044
+ results.style.display = "none";
1045
+ }, 120);
1046
+ });
1047
+ };
1048
+
1049
+ const renderHomeSections = (sections) => {
1050
+ if (!sections.length) {
1051
+ el.homeSections.innerHTML = '<div class="empty">No home sections configured.</div>';
1052
+ return;
1053
+ }
1054
+
1055
+ el.homeSections.innerHTML = "";
1056
+ sections.forEach((section) => {
1057
+ const wrap = document.createElement("article");
1058
+ wrap.className = "panel home-section-panel home-width-" + normalizeBoxWidth(section.width);
1059
+
1060
+ const heading = document.createElement("h3");
1061
+ heading.textContent = section.title;
1062
+ heading.style.margin = "0";
1063
+ wrap.appendChild(heading);
1064
+
1065
+ if (section.description) {
1066
+ const desc = document.createElement("div");
1067
+ desc.className = "subtitle";
1068
+ desc.textContent = section.description;
1069
+ wrap.appendChild(desc);
1070
+ }
1071
+
1072
+ const message = document.createElement("div");
1073
+ message.className = "home-message";
1074
+
1075
+ if (section.fields?.length) {
1076
+ const fieldsWrap = document.createElement("div");
1077
+ fieldsWrap.className = "home-fields";
1078
+
1079
+ section.fields.forEach((field) => {
1080
+ const fieldWrap = document.createElement("div");
1081
+ fieldWrap.className = "home-field";
1082
+
1083
+ const label = document.createElement("label");
1084
+ label.textContent = field.label;
1085
+ fieldWrap.appendChild(label);
1086
+
1087
+ let input;
1088
+ if (field.type === "textarea") {
1089
+ input = document.createElement("textarea");
1090
+ input.className = "home-textarea";
1091
+ input.value = field.value == null ? "" : String(field.value);
1092
+ } else if (field.type === "select") {
1093
+ input = document.createElement("select");
1094
+ input.className = "home-select";
1095
+ (field.options || []).forEach((option) => {
1096
+ const optionEl = document.createElement("option");
1097
+ optionEl.value = option.value;
1098
+ optionEl.textContent = option.label;
1099
+ if (String(field.value ?? "") === option.value) {
1100
+ optionEl.selected = true;
1101
+ }
1102
+ input.appendChild(optionEl);
1103
+ });
1104
+ } else if (field.type === "boolean") {
1105
+ const row = document.createElement("div");
1106
+ row.className = "home-field-row";
1107
+ input = document.createElement("input");
1108
+ input.type = "checkbox";
1109
+ input.className = "home-checkbox";
1110
+ input.checked = Boolean(field.value);
1111
+ const stateText = document.createElement("span");
1112
+ stateText.textContent = input.checked ? "Enabled" : "Disabled";
1113
+ input.addEventListener("change", () => {
1114
+ stateText.textContent = input.checked ? "Enabled" : "Disabled";
1115
+ });
1116
+ row.appendChild(input);
1117
+ row.appendChild(stateText);
1118
+ fieldWrap.appendChild(row);
1119
+ } else {
1120
+ input = document.createElement("input");
1121
+ input.className = "home-input";
1122
+ input.type = field.type === "number" ? "number" : "text";
1123
+ input.value = field.value == null ? "" : String(field.value);
1124
+ }
1125
+
1126
+ if (input) {
1127
+ input.dataset.homeFieldId = field.id;
1128
+ input.dataset.homeFieldType = field.type;
1129
+ if (field.placeholder && "placeholder" in input) {
1130
+ input.placeholder = field.placeholder;
1131
+ }
1132
+ if (field.required && "required" in input) {
1133
+ input.required = true;
1134
+ }
1135
+ if (field.readOnly) {
1136
+ if ("readOnly" in input) {
1137
+ input.readOnly = true;
1138
+ }
1139
+ if ("disabled" in input) {
1140
+ input.disabled = true;
1141
+ }
1142
+ }
1143
+
1144
+ if (field.type === "role-search" || field.type === "channel-search" || field.type === "member-search") {
1145
+ setupLookupField(field, input, fieldWrap);
1146
+ } else if (field.type !== "boolean") {
1147
+ fieldWrap.appendChild(input);
1148
+ }
1149
+ }
1150
+
1151
+ fieldsWrap.appendChild(fieldWrap);
1152
+ });
1153
+
1154
+ wrap.appendChild(fieldsWrap);
1155
+ }
1156
+
1157
+ if (section.actions?.length) {
1158
+ const actions = document.createElement("div");
1159
+ actions.className = "actions";
1160
+
1161
+ section.actions.forEach((action) => {
1162
+ const button = document.createElement("button");
1163
+ button.textContent = action.label;
1164
+ button.className = action.variant === "primary" ? "primary" : action.variant === "danger" ? "danger" : "";
1165
+ button.addEventListener("click", async () => {
1166
+ button.disabled = true;
1167
+ try {
1168
+ const values = {};
1169
+ const inputs = wrap.querySelectorAll("[data-home-field-id]");
1170
+ inputs.forEach((inputEl) => {
1171
+ const fieldId = inputEl.dataset.homeFieldId;
1172
+ const fieldType = inputEl.dataset.homeFieldType || "text";
1173
+ if (!fieldId) {
1174
+ return;
1175
+ }
1176
+
1177
+ values[fieldId] = toFieldValue({ type: fieldType }, inputEl);
1178
+ });
1179
+
1180
+ const result = await fetchJson(buildApiUrl("/api/home/" + encodeURIComponent(action.id)), {
1181
+ method: "POST",
1182
+ headers: { "Content-Type": "application/json" },
1183
+ body: JSON.stringify({
1184
+ sectionId: section.id,
1185
+ values
1186
+ })
1187
+ });
1188
+
1189
+ message.textContent = result.message || "Saved.";
1190
+ if (result.refresh) {
1191
+ await refreshContent();
1192
+ }
1193
+ } catch (error) {
1194
+ message.textContent = error instanceof Error ? error.message : "Save failed";
1195
+ } finally {
1196
+ button.disabled = false;
1197
+ }
1198
+ });
1199
+ actions.appendChild(button);
1200
+ });
1201
+
1202
+ wrap.appendChild(actions);
1203
+ }
1204
+
1205
+ wrap.appendChild(message);
1206
+ el.homeSections.appendChild(wrap);
1207
+ });
1208
+ };
1209
+
1210
+ const renderPlugins = (plugins) => {
1211
+ if (!plugins.length) {
1212
+ el.plugins.innerHTML = '<div class="empty">No plugins configured yet.</div>';
1213
+ return;
1214
+ }
1215
+
1216
+ el.plugins.innerHTML = "";
1217
+ plugins.forEach((plugin) => {
1218
+ const wrap = document.createElement("article");
1219
+ wrap.className = "panel";
1220
+
1221
+ const heading = document.createElement("div");
1222
+ heading.className = "title";
1223
+ heading.textContent = plugin.name;
1224
+ wrap.appendChild(heading);
1225
+
1226
+ if (plugin.description) {
1227
+ const desc = document.createElement("div");
1228
+ desc.className = "subtitle";
1229
+ desc.textContent = plugin.description;
1230
+ wrap.appendChild(desc);
1231
+ }
1232
+
1233
+ (plugin.panels || []).forEach((panel) => {
1234
+ const panelBody = document.createElement("div");
1235
+
1236
+ const panelTitle = document.createElement("h4");
1237
+ panelTitle.textContent = panel.title;
1238
+ panelTitle.style.marginBottom = "4px";
1239
+ panelBody.appendChild(panelTitle);
1240
+
1241
+ if (panel.description) {
1242
+ const p = document.createElement("div");
1243
+ p.className = "subtitle";
1244
+ p.textContent = panel.description;
1245
+ panelBody.appendChild(p);
1246
+ }
1247
+
1248
+ if (panel.fields?.length) {
1249
+ const fieldsWrap = document.createElement("div");
1250
+ fieldsWrap.className = "plugin-fields";
1251
+
1252
+ panel.fields.forEach((field) => {
1253
+ const fieldWrap = document.createElement("div");
1254
+ fieldWrap.className = field.editable ? "plugin-field" : "kv-item";
1255
+
1256
+ if (!field.editable) {
1257
+ const display = field.value == null
1258
+ ? ""
1259
+ : typeof field.value === "object"
1260
+ ? JSON.stringify(field.value)
1261
+ : String(field.value);
1262
+ fieldWrap.innerHTML = '<strong>' + escapeHtml(field.label) + '</strong><span>' + escapeHtml(display) + '</span>';
1263
+ fieldsWrap.appendChild(fieldWrap);
1264
+ return;
1265
+ }
1266
+
1267
+ const label = document.createElement("label");
1268
+ label.textContent = field.label;
1269
+ fieldWrap.appendChild(label);
1270
+
1271
+ let input;
1272
+ if (field.type === "textarea") {
1273
+ input = document.createElement("textarea");
1274
+ input.className = "home-textarea";
1275
+ input.value = field.value == null ? "" : String(field.value);
1276
+ } else if (field.type === "select") {
1277
+ input = document.createElement("select");
1278
+ input.className = "home-select";
1279
+ (field.options || []).forEach((option) => {
1280
+ const optionEl = document.createElement("option");
1281
+ optionEl.value = option.value;
1282
+ optionEl.textContent = option.label;
1283
+ if (String(field.value ?? "") === option.value) {
1284
+ optionEl.selected = true;
1285
+ }
1286
+ input.appendChild(optionEl);
1287
+ });
1288
+ } else if (field.type === "boolean") {
1289
+ const row = document.createElement("div");
1290
+ row.className = "home-field-row";
1291
+ input = document.createElement("input");
1292
+ input.type = "checkbox";
1293
+ input.className = "home-checkbox";
1294
+ input.checked = Boolean(field.value);
1295
+ const stateText = document.createElement("span");
1296
+ stateText.textContent = input.checked ? "Enabled" : "Disabled";
1297
+ input.addEventListener("change", () => {
1298
+ stateText.textContent = input.checked ? "Enabled" : "Disabled";
1299
+ });
1300
+ row.appendChild(input);
1301
+ row.appendChild(stateText);
1302
+ fieldWrap.appendChild(row);
1303
+ } else {
1304
+ input = document.createElement("input");
1305
+ input.className = "home-input";
1306
+ input.type = field.type === "number" ? "number" : field.type === "url" ? "url" : "text";
1307
+ input.value = field.value == null ? "" : String(field.value);
1308
+ }
1309
+
1310
+ if (input) {
1311
+ input.dataset.pluginFieldId = field.id || field.label;
1312
+ input.dataset.pluginFieldType = field.type || "text";
1313
+ if (field.placeholder && "placeholder" in input) {
1314
+ input.placeholder = field.placeholder;
1315
+ }
1316
+ if (field.required && "required" in input) {
1317
+ input.required = true;
1318
+ }
1319
+
1320
+ const isLookup = field.type === "role-search" || field.type === "channel-search" || field.type === "member-search";
1321
+ if (isLookup) {
1322
+ setupLookupField(field, input, fieldWrap);
1323
+ } else if (field.type === "string-list") {
1324
+ setupStringListField(field, input, fieldWrap);
1325
+ } else if (field.type !== "boolean") {
1326
+ fieldWrap.appendChild(input);
1327
+ }
1328
+ }
1329
+
1330
+ fieldsWrap.appendChild(fieldWrap);
1331
+ });
1332
+ panelBody.appendChild(fieldsWrap);
1333
+ }
1334
+
1335
+ if (panel.actions?.length) {
1336
+ const actions = document.createElement("div");
1337
+ actions.className = "actions";
1338
+ panel.actions.forEach((action) => {
1339
+ actions.appendChild(makeButton(action, plugin.id, panel.id, panelBody));
1340
+ });
1341
+ panelBody.appendChild(actions);
1342
+ }
1343
+
1344
+ wrap.appendChild(panelBody);
1345
+ });
1346
+
1347
+ el.plugins.appendChild(wrap);
1348
+ });
1349
+ };
1350
+
1351
+ const refreshContent = async () => {
1352
+ const categoriesPayload = await fetchJson(buildApiUrl("/api/home/categories"));
1353
+ state.homeCategories = categoriesPayload.categories || [];
1354
+ if (!state.selectedHomeCategoryId || !state.homeCategories.some((item) => item.id === state.selectedHomeCategoryId)) {
1355
+ const overviewCategory = state.homeCategories.find((item) => item.id === "overview");
1356
+ state.selectedHomeCategoryId = overviewCategory ? overviewCategory.id : (state.homeCategories[0]?.id ?? null);
1357
+ }
1358
+ renderHomeCategories();
1359
+
1360
+ const homePath = state.selectedHomeCategoryId
1361
+ ? "/api/home?categoryId=" + encodeURIComponent(state.selectedHomeCategoryId)
1362
+ : "/api/home";
1363
+
1364
+ const [home, overview, plugins] = await Promise.all([
1365
+ fetchJson(buildApiUrl(homePath)),
1366
+ fetchJson(buildApiUrl("/api/overview")),
1367
+ fetchJson(buildApiUrl("/api/plugins"))
1368
+ ]);
1369
+
1370
+ renderHomeSections(home.sections || []);
1371
+ const showOverviewArea = state.selectedHomeCategoryId === "overview";
1372
+ el.overviewArea.style.display = showOverviewArea ? "block" : "none";
1373
+ renderCards(overview.cards || []);
1374
+ renderPlugins(plugins.plugins || []);
1375
+ };
1376
+
1377
+ const loadInitialData = async () => {
1378
+ applySetupDesign();
1379
+
1380
+ const session = await fetchJson(dashboardConfig.basePath + "/api/session");
1381
+ if (!session.authenticated) {
1382
+ window.location.href = dashboardConfig.basePath + "/login";
1383
+ return;
1384
+ }
1385
+
1386
+ state.session = session;
1387
+ el.userMeta.textContent = session.user.username + " \u2022 " + session.guildCount + " guild(s)";
1388
+ const guilds = await fetchJson(dashboardConfig.basePath + "/api/guilds");
1389
+ state.guilds = guilds.guilds || [];
1390
+ state.selectedGuildId = null;
1391
+ renderServerRail();
1392
+ updateContextLabel();
1393
+
1394
+ el.tabHome.addEventListener("click", () => {
1395
+ state.activeMainTab = "home";
1396
+ applyMainTab();
1397
+ });
1398
+
1399
+ el.tabPlugins.addEventListener("click", () => {
1400
+ state.activeMainTab = "plugins";
1401
+ applyMainTab();
1402
+ });
1403
+
1404
+ applyMainTab();
1405
+ await refreshContent();
1406
+ };
1407
+
1408
+ loadInitialData().catch((error) => {
1409
+ el.userMeta.textContent = "Load failed";
1410
+ console.error(error);
1411
+ });
1412
+ </script>
1413
+ </body>
1414
+ </html>`;
1415
+ }
1416
+
1417
+ // src/templates/compact.ts
1418
+ function escapeHtml2(value) {
1419
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
1420
+ }
1421
+ function extractDashboardScript(html) {
1422
+ const match = html.match(/<script>([\s\S]*)<\/script>\s*<\/body>\s*<\/html>\s*$/);
1423
+ if (!match) {
1424
+ throw new Error("Failed to resolve dashboard script for compact template.");
1425
+ }
1426
+ return match[1];
1427
+ }
1428
+ var compactCss = `
1429
+ :root {
1430
+ color-scheme: dark;
1431
+ --bg: #0f1221;
1432
+ --rail: #171a2d;
1433
+ --content-bg: #0f1426;
1434
+ --panel: #1f243b;
1435
+ --panel-2: #2a314e;
1436
+ --text: #f5f7ff;
1437
+ --muted: #aab1d6;
1438
+ --primary: #7c87ff;
1439
+ --success: #2bd4a6;
1440
+ --warning: #ffd166;
1441
+ --danger: #ff6f91;
1442
+ --info: #66d9ff;
1443
+ --border: rgba(255, 255, 255, 0.12);
1444
+ }
1445
+ * { box-sizing: border-box; }
1446
+ body {
1447
+ margin: 0;
1448
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
1449
+ background: radial-gradient(circle at 0% 0%, #1b2140 0%, var(--bg) 45%);
1450
+ color: var(--text);
1451
+ }
1452
+ .shell {
1453
+ min-height: 100vh;
1454
+ display: grid;
1455
+ grid-template-rows: auto 1fr;
1456
+ }
1457
+ .topbar {
1458
+ display: grid;
1459
+ grid-template-columns: auto 1fr auto;
1460
+ align-items: center;
1461
+ gap: 10px;
1462
+ padding: 10px 14px;
1463
+ border-bottom: 1px solid var(--border);
1464
+ background: rgba(15, 20, 38, 0.7);
1465
+ backdrop-filter: blur(8px);
1466
+ }
1467
+ .brand { font-weight: 800; letter-spacing: .2px; }
1468
+ .center-title { text-align: center; font-weight: 700; color: #d8defc; }
1469
+ .pill {
1470
+ justify-self: end;
1471
+ padding: 4px 8px;
1472
+ border-radius: 999px;
1473
+ border: 1px solid var(--border);
1474
+ color: var(--muted);
1475
+ font-size: .75rem;
1476
+ }
1477
+ .layout {
1478
+ display: grid;
1479
+ grid-template-columns: 80px 1fr;
1480
+ min-height: 0;
1481
+ }
1482
+ .sidebar {
1483
+ border-right: 1px solid var(--border);
1484
+ background: linear-gradient(180deg, var(--rail), #121528);
1485
+ padding: 10px 0;
1486
+ }
1487
+ .server-rail {
1488
+ display: flex;
1489
+ flex-direction: column;
1490
+ align-items: center;
1491
+ gap: 10px;
1492
+ }
1493
+ .server-item {
1494
+ position: relative;
1495
+ width: 46px;
1496
+ height: 46px;
1497
+ border: none;
1498
+ border-radius: 14px;
1499
+ overflow: visible;
1500
+ background: var(--panel);
1501
+ color: #fff;
1502
+ font-weight: 700;
1503
+ cursor: pointer;
1504
+ transition: transform .15s ease, background .15s ease;
1505
+ }
1506
+ .server-item:hover { transform: translateY(-1px); background: #323b5f; }
1507
+ .server-item.active { background: var(--primary); transform: translateY(-2px); }
1508
+ .server-item-indicator {
1509
+ position: absolute;
1510
+ left: -8px;
1511
+ top: 50%;
1512
+ transform: translateY(-50%) scaleY(.5);
1513
+ width: 3px;
1514
+ height: 18px;
1515
+ background: #fff;
1516
+ border-radius: 999px;
1517
+ opacity: 0;
1518
+ transition: opacity .15s ease, transform .15s ease;
1519
+ }
1520
+ .server-item.active .server-item-indicator {
1521
+ opacity: 1;
1522
+ transform: translateY(-50%) scaleY(1);
1523
+ }
1524
+ .server-avatar { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
1525
+ .server-fallback { display: grid; place-items: center; width: 100%; height: 100%; }
1526
+ .server-status {
1527
+ position: absolute;
1528
+ right: -3px;
1529
+ bottom: -3px;
1530
+ width: 11px;
1531
+ height: 11px;
1532
+ border-radius: 999px;
1533
+ border: 2px solid var(--rail);
1534
+ background: #35d489;
1535
+ }
1536
+ .server-status.offline { background: #7f8bb3; }
1537
+ .content {
1538
+ min-width: 0;
1539
+ padding: 12px;
1540
+ }
1541
+ .container {
1542
+ background: rgba(23, 28, 48, 0.6);
1543
+ border: 1px solid var(--border);
1544
+ border-radius: 12px;
1545
+ padding: 12px;
1546
+ }
1547
+ .main-tabs { display: flex; gap: 8px; margin-bottom: 10px; }
1548
+ button {
1549
+ border: 1px solid var(--border);
1550
+ background: var(--panel-2);
1551
+ color: var(--text);
1552
+ border-radius: 8px;
1553
+ padding: 7px 10px;
1554
+ cursor: pointer;
1555
+ }
1556
+ button.primary { background: var(--primary); border: none; }
1557
+ button.danger { background: #4a2230; border-color: rgba(255,111,145,.45); }
1558
+ .main-tab.active, .home-category-btn.active { background: var(--primary); border-color: transparent; }
1559
+ .section-title { margin: 12px 0 8px; color: #dce3ff; font-size: .95rem; }
1560
+ .grid { display: grid; gap: 10px; }
1561
+ .cards { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
1562
+ .panel {
1563
+ background: linear-gradient(180deg, rgba(42,49,78,.7), rgba(31,36,59,.85));
1564
+ border: 1px solid var(--border);
1565
+ border-radius: 10px;
1566
+ padding: 12px;
1567
+ }
1568
+ .title { color: var(--muted); font-size: .83rem; }
1569
+ .value { font-size: 1.25rem; font-weight: 800; margin-top: 5px; }
1570
+ .subtitle { margin-top: 5px; color: var(--muted); font-size: .8rem; }
1571
+ .home-categories { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
1572
+ .home-sections { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
1573
+ .home-section-panel { flex: 0 0 100%; max-width: 100%; }
1574
+ .home-width-50 { flex-basis: calc(50% - 5px); max-width: calc(50% - 5px); }
1575
+ .home-width-33 { flex-basis: calc(33.333333% - 6.67px); max-width: calc(33.333333% - 6.67px); }
1576
+ .home-width-20 { flex-basis: calc(20% - 8px); max-width: calc(20% - 8px); }
1577
+ .home-fields, .plugin-fields { display: grid; gap: 8px; margin-top: 8px; }
1578
+ .home-field, .plugin-field { display: grid; gap: 5px; }
1579
+ .home-field label, .plugin-field > label { color: var(--muted); font-size: .8rem; }
1580
+ .home-input, .home-textarea, .home-select {
1581
+ width: 100%;
1582
+ border: 1px solid var(--border);
1583
+ background: var(--panel-2);
1584
+ color: var(--text);
1585
+ border-radius: 8px;
1586
+ padding: 7px 9px;
1587
+ }
1588
+ .home-textarea { min-height: 88px; resize: vertical; }
1589
+ .home-checkbox { width: 17px; height: 17px; }
1590
+ .home-field-row { display: flex; align-items: center; gap: 8px; }
1591
+ .home-message { margin-top: 6px; color: var(--muted); font-size: .8rem; }
1592
+ .lookup-wrap { position: relative; }
1593
+ .lookup-results {
1594
+ position: absolute;
1595
+ left: 0;
1596
+ right: 0;
1597
+ top: calc(100% + 5px);
1598
+ z-index: 20;
1599
+ border: 1px solid var(--border);
1600
+ background: #1f2742;
1601
+ border-radius: 8px;
1602
+ max-height: 220px;
1603
+ overflow: auto;
1604
+ display: none;
1605
+ }
1606
+ .lookup-item {
1607
+ width: 100%;
1608
+ border: none;
1609
+ border-radius: 0;
1610
+ text-align: left;
1611
+ padding: 8px 10px;
1612
+ background: transparent;
1613
+ }
1614
+ .lookup-item:hover { background: #2d3658; }
1615
+ .lookup-selected { margin-top: 5px; font-size: .8rem; color: var(--muted); }
1616
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
1617
+ .kv-item {
1618
+ display: flex;
1619
+ justify-content: space-between;
1620
+ border: 1px solid var(--border);
1621
+ border-radius: 8px;
1622
+ padding: 7px 9px;
1623
+ background: var(--panel-2);
1624
+ }
1625
+ .list-editor {
1626
+ border: 1px solid var(--border);
1627
+ border-radius: 8px;
1628
+ background: var(--panel-2);
1629
+ padding: 8px;
1630
+ display: grid;
1631
+ gap: 8px;
1632
+ }
1633
+ .list-items { display: grid; gap: 6px; }
1634
+ .list-item {
1635
+ display: grid;
1636
+ grid-template-columns: auto 1fr auto;
1637
+ gap: 8px;
1638
+ align-items: center;
1639
+ border: 1px solid var(--border);
1640
+ border-radius: 8px;
1641
+ padding: 6px 8px;
1642
+ background: var(--panel);
1643
+ }
1644
+ .list-item.dragging { opacity: .6; }
1645
+ .drag-handle { color: var(--muted); user-select: none; cursor: grab; font-size: .9rem; }
1646
+ .list-input { width: 100%; border: none; outline: none; background: transparent; color: var(--text); }
1647
+ .list-add { justify-self: start; }
1648
+ .empty { color: var(--muted); font-size: .9rem; }
1649
+ @media (max-width: 980px) {
1650
+ .layout { grid-template-columns: 70px 1fr; }
1651
+ .home-width-50, .home-width-33, .home-width-20 { flex-basis: 100%; max-width: 100%; }
1652
+ }
1653
+ `;
1654
+ var compactDashboardTemplateRenderer = ({
1655
+ dashboardName,
1656
+ basePath,
1657
+ setupDesign
1658
+ }) => {
1659
+ const script = extractDashboardScript(renderDashboardHtml(dashboardName, basePath, setupDesign));
1660
+ const safeName = escapeHtml2(dashboardName);
1661
+ return `<!DOCTYPE html>
1662
+ <html lang="en">
1663
+ <head>
1664
+ <meta charset="UTF-8" />
1665
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1666
+ <title>${safeName}</title>
1667
+ <style>${compactCss}</style>
1668
+ </head>
1669
+ <body>
1670
+ <div class="shell">
1671
+ <header class="topbar">
1672
+ <div class="brand">${safeName}</div>
1673
+ <div id="centerTitle" class="center-title">User Dashboard</div>
1674
+ <div id="userMeta" class="pill">Loading...</div>
1675
+ </header>
1676
+
1677
+ <div class="layout">
1678
+ <aside class="sidebar">
1679
+ <div id="serverRail" class="server-rail"></div>
1680
+ </aside>
1681
+
1682
+ <main class="content">
1683
+ <div class="container">
1684
+ <div class="main-tabs">
1685
+ <button id="tabHome" class="main-tab active">Home</button>
1686
+ <button id="tabPlugins" class="main-tab">Plugins</button>
1687
+ </div>
1688
+
1689
+ <section id="homeArea">
1690
+ <div class="section-title">Home</div>
1691
+ <section id="homeCategories" class="home-categories"></section>
1692
+ <section id="homeSections" class="home-sections"></section>
1693
+
1694
+ <section id="overviewArea">
1695
+ <div class="section-title">Dashboard Stats</div>
1696
+ <section id="overviewCards" class="grid cards"></section>
1697
+ </section>
1698
+ </section>
1699
+
1700
+ <section id="pluginsArea" style="display:none;">
1701
+ <div class="section-title">Plugins</div>
1702
+ <section id="plugins" class="grid"></section>
1703
+ </section>
1704
+ </div>
1705
+ </main>
1706
+ </div>
1707
+ </div>
1708
+
1709
+ <script>${script}</script>
1710
+ </body>
1711
+ </html>`;
1712
+ };
1713
+
1714
+ // src/templates/default.ts
1715
+ var defaultDashboardTemplateRenderer = ({
1716
+ dashboardName,
1717
+ basePath,
1718
+ setupDesign
1719
+ }) => renderDashboardHtml(dashboardName, basePath, setupDesign);
1720
+
1721
+ // src/templates/index.ts
1722
+ var builtinTemplateRenderers = {
1723
+ default: defaultDashboardTemplateRenderer,
1724
+ compact: compactDashboardTemplateRenderer
1725
+ };
1726
+ function getBuiltinTemplateRenderer(templateId) {
1727
+ return builtinTemplateRenderers[templateId];
1728
+ }
1729
+
1730
+ // src/dashboard.ts
1731
+ var DISCORD_API2 = "https://discord.com/api/v10";
1732
+ var MANAGE_GUILD_PERMISSION = 0x20n;
1733
+ var ADMIN_PERMISSION = 0x8n;
1734
+ function normalizeBasePath(basePath) {
1735
+ if (!basePath || basePath === "/") {
1736
+ return "/dashboard";
1737
+ }
1738
+ return basePath.startsWith("/") ? basePath : `/${basePath}`;
1739
+ }
1740
+ function canManageGuild(permissions) {
1741
+ const value = BigInt(permissions);
1742
+ return (value & MANAGE_GUILD_PERMISSION) === MANAGE_GUILD_PERMISSION || (value & ADMIN_PERMISSION) === ADMIN_PERMISSION;
1743
+ }
1744
+ function toQuery(params) {
1745
+ const url = new URLSearchParams();
1746
+ for (const [key, value] of Object.entries(params)) {
1747
+ url.set(key, value);
1748
+ }
1749
+ return url.toString();
1750
+ }
1751
+ async function fetchDiscord(path, token) {
1752
+ const response = await fetch(`${DISCORD_API2}${path}`, {
1753
+ headers: {
1754
+ Authorization: `Bearer ${token}`
1755
+ }
1756
+ });
1757
+ if (!response.ok) {
1758
+ throw new Error(`Discord API request failed (${response.status})`);
1759
+ }
1760
+ return await response.json();
1761
+ }
1762
+ async function exchangeCodeForToken(options, code) {
1763
+ const response = await fetch(`${DISCORD_API2}/oauth2/token`, {
1764
+ method: "POST",
1765
+ headers: {
1766
+ "Content-Type": "application/x-www-form-urlencoded"
1767
+ },
1768
+ body: toQuery({
1769
+ client_id: options.clientId,
1770
+ client_secret: options.clientSecret,
1771
+ grant_type: "authorization_code",
1772
+ code,
1773
+ redirect_uri: options.redirectUri
1774
+ })
1775
+ });
1776
+ if (!response.ok) {
1777
+ const text = await response.text();
1778
+ throw new Error(`Failed token exchange: ${response.status} ${text}`);
1779
+ }
1780
+ return await response.json();
1781
+ }
1782
+ function createContext(req, options) {
1783
+ const auth = req.session.discordAuth;
1784
+ if (!auth) {
1785
+ throw new Error("Not authenticated");
1786
+ }
1787
+ const selectedGuildId = typeof req.query.guildId === "string" ? req.query.guildId : void 0;
1788
+ return {
1789
+ user: auth.user,
1790
+ guilds: auth.guilds,
1791
+ accessToken: auth.accessToken,
1792
+ selectedGuildId,
1793
+ helpers: createDiscordHelpers(options.botToken)
1794
+ };
1795
+ }
1796
+ function ensureAuthenticated(req, res, next) {
1797
+ if (!req.session.discordAuth) {
1798
+ res.status(401).json({ authenticated: false, message: "Authentication required" });
1799
+ return;
1800
+ }
1801
+ next();
1802
+ }
1803
+ async function resolveOverviewCards(options, context) {
1804
+ if (options.getOverviewCards) {
1805
+ return await options.getOverviewCards(context);
1806
+ }
1807
+ const manageableGuildCount = context.guilds.filter((guild) => guild.owner || canManageGuild(guild.permissions)).length;
1808
+ return [
1809
+ {
1810
+ id: "user",
1811
+ title: "Logged-in User",
1812
+ value: context.user.global_name || context.user.username,
1813
+ subtitle: `ID: ${context.user.id}`,
1814
+ intent: "info"
1815
+ },
1816
+ {
1817
+ id: "guilds",
1818
+ title: "Manageable Guilds",
1819
+ value: manageableGuildCount,
1820
+ subtitle: "Owner or Manage Server permissions",
1821
+ intent: "success"
1822
+ },
1823
+ {
1824
+ id: "plugins",
1825
+ title: "Plugins Loaded",
1826
+ value: options.plugins?.length ?? 0,
1827
+ subtitle: "Dynamic server modules",
1828
+ intent: "neutral"
1829
+ }
1830
+ ];
1831
+ }
1832
+ async function resolveHomeSections(options, context) {
1833
+ const customSections = options.home?.getSections ? await options.home.getSections(context) : [];
1834
+ const overviewSections = options.home?.getOverviewSections ? await options.home.getOverviewSections(context) : [];
1835
+ if (customSections.length > 0 || overviewSections.length > 0) {
1836
+ const normalizedOverview = overviewSections.map((section) => ({
1837
+ ...section,
1838
+ categoryId: section.categoryId ?? "overview"
1839
+ }));
1840
+ return [...normalizedOverview, ...customSections];
1841
+ }
1842
+ const selectedGuild = context.selectedGuildId ? context.guilds.find((guild) => guild.id === context.selectedGuildId) : void 0;
1843
+ return [
1844
+ {
1845
+ id: "setup",
1846
+ title: "Setup Details",
1847
+ description: "Core dashboard setup information",
1848
+ scope: "setup",
1849
+ categoryId: "setup",
1850
+ fields: [
1851
+ {
1852
+ id: "dashboardName",
1853
+ label: "Dashboard Name",
1854
+ type: "text",
1855
+ value: options.dashboardName ?? "Discord Dashboard",
1856
+ readOnly: true
1857
+ },
1858
+ {
1859
+ id: "basePath",
1860
+ label: "Base Path",
1861
+ type: "text",
1862
+ value: options.basePath ?? "/dashboard",
1863
+ readOnly: true
1864
+ }
1865
+ ]
1866
+ },
1867
+ {
1868
+ id: "context",
1869
+ title: "Dashboard Context",
1870
+ description: selectedGuild ? `Managing ${selectedGuild.name}` : "Managing user dashboard",
1871
+ scope: resolveScope(context),
1872
+ categoryId: "overview",
1873
+ fields: [
1874
+ {
1875
+ id: "mode",
1876
+ label: "Mode",
1877
+ type: "text",
1878
+ value: selectedGuild ? "Guild" : "User",
1879
+ readOnly: true
1880
+ },
1881
+ {
1882
+ id: "target",
1883
+ label: "Target",
1884
+ type: "text",
1885
+ value: selectedGuild ? selectedGuild.name : context.user.username,
1886
+ readOnly: true
1887
+ }
1888
+ ]
1889
+ }
1890
+ ];
1891
+ }
1892
+ function resolveScope(context) {
1893
+ return context.selectedGuildId ? "guild" : "user";
1894
+ }
1895
+ async function resolveHomeCategories(options, context) {
1896
+ if (options.home?.getCategories) {
1897
+ const categories = await options.home.getCategories(context);
1898
+ return [...categories].sort((a, b) => {
1899
+ if (a.id === "overview") return -1;
1900
+ if (b.id === "overview") return 1;
1901
+ return 0;
1902
+ });
1903
+ }
1904
+ return [
1905
+ { id: "overview", label: "Overview", scope: resolveScope(context) },
1906
+ { id: "setup", label: "Setup", scope: "setup" }
1907
+ ];
1908
+ }
1909
+ function getUserAvatarUrl(user) {
1910
+ if (user.avatar) {
1911
+ const ext = user.avatar.startsWith("a_") ? "gif" : "png";
1912
+ return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=256`;
1913
+ }
1914
+ const fallbackIndex = Number((BigInt(user.id) >> 22n) % 6n);
1915
+ return `https://cdn.discordapp.com/embed/avatars/${fallbackIndex}.png`;
1916
+ }
1917
+ function getGuildIconUrl(guild) {
1918
+ if (!guild.icon) {
1919
+ return null;
1920
+ }
1921
+ const ext = guild.icon.startsWith("a_") ? "gif" : "png";
1922
+ return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.${ext}?size=128`;
1923
+ }
1924
+ function createGuildInviteUrl(options, guildId) {
1925
+ const scopes = options.botInviteScopes && options.botInviteScopes.length > 0 ? options.botInviteScopes : ["bot", "applications.commands"];
1926
+ return `https://discord.com/oauth2/authorize?${toQuery({
1927
+ client_id: options.clientId,
1928
+ scope: scopes.join(" "),
1929
+ permissions: options.botInvitePermissions ?? "8",
1930
+ guild_id: guildId,
1931
+ disable_guild_select: "true"
1932
+ })}`;
1933
+ }
1934
+ async function fetchBotGuildIds(botToken) {
1935
+ const response = await fetch(`${DISCORD_API2}/users/@me/guilds`, {
1936
+ headers: {
1937
+ Authorization: `Bot ${botToken}`
1938
+ }
1939
+ });
1940
+ if (!response.ok) {
1941
+ return /* @__PURE__ */ new Set();
1942
+ }
1943
+ const guilds = await response.json();
1944
+ return new Set(guilds.map((guild) => guild.id));
1945
+ }
1946
+ function resolveTemplateRenderer(options) {
1947
+ const selectedTemplate = options.uiTemplate ?? "default";
1948
+ const defaultRenderer = ({ dashboardName, basePath, setupDesign }) => renderDashboardHtml(dashboardName, basePath, setupDesign);
1949
+ const customRenderer = options.uiTemplates?.[selectedTemplate];
1950
+ if (customRenderer) {
1951
+ return customRenderer;
1952
+ }
1953
+ const builtinRenderer = getBuiltinTemplateRenderer(selectedTemplate);
1954
+ if (builtinRenderer) {
1955
+ return builtinRenderer;
1956
+ }
1957
+ if (selectedTemplate !== "default") {
1958
+ throw new Error(`Unknown uiTemplate '${selectedTemplate}'. Register it in uiTemplates.`);
1959
+ }
1960
+ return defaultRenderer;
1961
+ }
1962
+ function createDashboard(options) {
1963
+ const app = options.app ?? (0, import_express.default)();
1964
+ const basePath = normalizeBasePath(options.basePath);
1965
+ const dashboardName = options.dashboardName ?? "Discord Dashboard";
1966
+ const templateRenderer = resolveTemplateRenderer(options);
1967
+ const plugins = options.plugins ?? [];
1968
+ if (!options.botToken) throw new Error("botToken is required");
1969
+ if (!options.clientId) throw new Error("clientId is required");
1970
+ if (!options.clientSecret) throw new Error("clientSecret is required");
1971
+ if (!options.redirectUri) throw new Error("redirectUri is required");
1972
+ if (!options.sessionSecret) throw new Error("sessionSecret is required");
1973
+ if (!options.app && options.trustProxy !== void 0) {
1974
+ app.set("trust proxy", options.trustProxy);
1975
+ }
1976
+ const router = import_express.default.Router();
1977
+ const sessionMiddleware = (0, import_express_session.default)({
1978
+ name: options.sessionName ?? "discord_dashboard.sid",
1979
+ secret: options.sessionSecret,
1980
+ resave: false,
1981
+ saveUninitialized: false,
1982
+ cookie: {
1983
+ httpOnly: true,
1984
+ sameSite: "lax",
1985
+ maxAge: options.sessionMaxAgeMs ?? 1e3 * 60 * 60 * 24 * 7
1986
+ }
1987
+ });
1988
+ router.use((0, import_compression.default)());
1989
+ router.use(
1990
+ (0, import_helmet.default)({
1991
+ contentSecurityPolicy: false
1992
+ })
1993
+ );
1994
+ router.use(import_express.default.json());
1995
+ router.use(sessionMiddleware);
1996
+ router.get("/", (req, res) => {
1997
+ if (!req.session.discordAuth) {
1998
+ res.redirect(`${basePath}/login`);
1999
+ return;
2000
+ }
2001
+ res.setHeader("Cache-Control", "no-store");
2002
+ res.type("html").send(templateRenderer({
2003
+ dashboardName,
2004
+ basePath,
2005
+ setupDesign: options.setupDesign
2006
+ }));
2007
+ });
2008
+ router.get("/login", (req, res) => {
2009
+ const state = (0, import_node_crypto.randomBytes)(16).toString("hex");
2010
+ req.session.oauthState = state;
2011
+ const scope = (options.scopes && options.scopes.length > 0 ? options.scopes : ["identify", "guilds"]).join(" ");
2012
+ const query = toQuery({
2013
+ client_id: options.clientId,
2014
+ redirect_uri: options.redirectUri,
2015
+ response_type: "code",
2016
+ scope,
2017
+ state,
2018
+ prompt: "none"
2019
+ });
2020
+ res.redirect(`https://discord.com/oauth2/authorize?${query}`);
2021
+ });
2022
+ router.get("/callback", async (req, res) => {
2023
+ try {
2024
+ const code = typeof req.query.code === "string" ? req.query.code : void 0;
2025
+ const state = typeof req.query.state === "string" ? req.query.state : void 0;
2026
+ if (!code || !state) {
2027
+ res.status(400).send("Missing OAuth2 code/state");
2028
+ return;
2029
+ }
2030
+ if (!req.session.oauthState || req.session.oauthState !== state) {
2031
+ res.status(403).send("Invalid OAuth2 state");
2032
+ return;
2033
+ }
2034
+ const tokenData = await exchangeCodeForToken(options, code);
2035
+ const [user, guilds] = await Promise.all([
2036
+ fetchDiscord("/users/@me", tokenData.access_token),
2037
+ fetchDiscord("/users/@me/guilds", tokenData.access_token)
2038
+ ]);
2039
+ req.session.discordAuth = {
2040
+ accessToken: tokenData.access_token,
2041
+ refreshToken: tokenData.refresh_token,
2042
+ expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1e3 : void 0,
2043
+ user,
2044
+ guilds
2045
+ };
2046
+ req.session.oauthState = void 0;
2047
+ res.redirect(basePath);
2048
+ } catch (error) {
2049
+ const message = error instanceof Error ? error.message : "OAuth callback failed";
2050
+ res.status(500).send(message);
2051
+ }
2052
+ });
2053
+ router.post("/logout", (req, res) => {
2054
+ req.session.destroy((sessionError) => {
2055
+ if (sessionError) {
2056
+ res.status(500).json({ ok: false, message: "Failed to destroy session" });
2057
+ return;
2058
+ }
2059
+ res.clearCookie(options.sessionName ?? "discord_dashboard.sid");
2060
+ res.json({ ok: true });
2061
+ });
2062
+ });
2063
+ router.get("/api/session", (req, res) => {
2064
+ const auth = req.session.discordAuth;
2065
+ if (!auth) {
2066
+ res.status(200).json({ authenticated: false });
2067
+ return;
2068
+ }
2069
+ const manageableGuildCount = auth.guilds.filter((guild) => guild.owner || canManageGuild(guild.permissions)).length;
2070
+ res.json({
2071
+ authenticated: true,
2072
+ user: {
2073
+ ...auth.user,
2074
+ avatarUrl: getUserAvatarUrl(auth.user)
2075
+ },
2076
+ guildCount: manageableGuildCount,
2077
+ expiresAt: auth.expiresAt
2078
+ });
2079
+ });
2080
+ router.get("/api/guilds", ensureAuthenticated, async (req, res) => {
2081
+ const context = createContext(req, options);
2082
+ if (options.ownerIds && options.ownerIds.length > 0 && !options.ownerIds.includes(context.user.id)) {
2083
+ res.status(403).json({ message: "You are not allowed to access this dashboard." });
2084
+ return;
2085
+ }
2086
+ let manageableGuilds = context.guilds.filter((guild) => guild.owner || canManageGuild(guild.permissions));
2087
+ if (options.guildFilter) {
2088
+ const filtered = [];
2089
+ for (const guild of manageableGuilds) {
2090
+ const allowed = await options.guildFilter(guild, context);
2091
+ if (allowed) {
2092
+ filtered.push(guild);
2093
+ }
2094
+ }
2095
+ manageableGuilds = filtered;
2096
+ }
2097
+ const botGuildIds = await fetchBotGuildIds(options.botToken);
2098
+ const enrichedGuilds = manageableGuilds.map((guild) => {
2099
+ const botInGuild = botGuildIds.has(guild.id);
2100
+ return {
2101
+ ...guild,
2102
+ iconUrl: getGuildIconUrl(guild),
2103
+ botInGuild,
2104
+ inviteUrl: botInGuild ? void 0 : createGuildInviteUrl(options, guild.id)
2105
+ };
2106
+ });
2107
+ res.json({ guilds: enrichedGuilds });
2108
+ });
2109
+ router.get("/api/overview", ensureAuthenticated, async (req, res) => {
2110
+ const context = createContext(req, options);
2111
+ const cards = await resolveOverviewCards(options, context);
2112
+ res.json({ cards });
2113
+ });
2114
+ router.get("/api/home/categories", ensureAuthenticated, async (req, res) => {
2115
+ const context = createContext(req, options);
2116
+ const activeScope = resolveScope(context);
2117
+ const categories = await resolveHomeCategories(options, context);
2118
+ const visible = categories.filter((item) => item.scope === "setup" || item.scope === activeScope);
2119
+ res.json({ categories: visible, activeScope });
2120
+ });
2121
+ router.get("/api/home", ensureAuthenticated, async (req, res) => {
2122
+ const context = createContext(req, options);
2123
+ const activeScope = resolveScope(context);
2124
+ const categoryId = typeof req.query.categoryId === "string" ? req.query.categoryId : void 0;
2125
+ let sections = await resolveHomeSections(options, context);
2126
+ sections = sections.filter((section) => {
2127
+ const sectionScope = section.scope ?? activeScope;
2128
+ if (sectionScope !== "setup" && sectionScope !== activeScope) {
2129
+ return false;
2130
+ }
2131
+ if (!categoryId) {
2132
+ return true;
2133
+ }
2134
+ return section.categoryId === categoryId;
2135
+ });
2136
+ res.json({ sections, activeScope });
2137
+ });
2138
+ router.get("/api/lookup/roles", ensureAuthenticated, async (req, res) => {
2139
+ const context = createContext(req, options);
2140
+ const guildId = typeof req.query.guildId === "string" && req.query.guildId.length > 0 ? req.query.guildId : context.selectedGuildId;
2141
+ if (!guildId) {
2142
+ res.status(400).json({ message: "guildId is required" });
2143
+ return;
2144
+ }
2145
+ const query = typeof req.query.q === "string" ? req.query.q : "";
2146
+ const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : void 0;
2147
+ const includeManaged = typeof req.query.includeManaged === "string" ? req.query.includeManaged === "true" : void 0;
2148
+ const roles = await context.helpers.searchGuildRoles(guildId, query, {
2149
+ limit: Number.isFinite(limit) ? limit : void 0,
2150
+ includeManaged
2151
+ });
2152
+ res.json({ roles });
2153
+ });
2154
+ router.get("/api/lookup/channels", ensureAuthenticated, async (req, res) => {
2155
+ const context = createContext(req, options);
2156
+ const guildId = typeof req.query.guildId === "string" && req.query.guildId.length > 0 ? req.query.guildId : context.selectedGuildId;
2157
+ if (!guildId) {
2158
+ res.status(400).json({ message: "guildId is required" });
2159
+ return;
2160
+ }
2161
+ const query = typeof req.query.q === "string" ? req.query.q : "";
2162
+ const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : void 0;
2163
+ const nsfw = typeof req.query.nsfw === "string" ? req.query.nsfw === "true" : void 0;
2164
+ const channelTypes = typeof req.query.channelTypes === "string" ? req.query.channelTypes.split(",").map((item) => Number(item.trim())).filter((item) => Number.isFinite(item)) : void 0;
2165
+ const channels = await context.helpers.searchGuildChannels(guildId, query, {
2166
+ limit: Number.isFinite(limit) ? limit : void 0,
2167
+ nsfw,
2168
+ channelTypes
2169
+ });
2170
+ res.json({ channels });
2171
+ });
2172
+ router.get("/api/lookup/members", ensureAuthenticated, async (req, res) => {
2173
+ const context = createContext(req, options);
2174
+ const guildId = typeof req.query.guildId === "string" && req.query.guildId.length > 0 ? req.query.guildId : context.selectedGuildId;
2175
+ if (!guildId) {
2176
+ res.status(400).json({ message: "guildId is required" });
2177
+ return;
2178
+ }
2179
+ const query = typeof req.query.q === "string" ? req.query.q : "";
2180
+ const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : void 0;
2181
+ const members = await context.helpers.searchGuildMembers(guildId, query, {
2182
+ limit: Number.isFinite(limit) ? limit : void 0
2183
+ });
2184
+ res.json({ members });
2185
+ });
2186
+ router.post("/api/home/:actionId", ensureAuthenticated, async (req, res) => {
2187
+ const context = createContext(req, options);
2188
+ const action = options.home?.actions?.[req.params.actionId];
2189
+ if (!action) {
2190
+ res.status(404).json({ ok: false, message: "Home action not found" });
2191
+ return;
2192
+ }
2193
+ const payload = req.body;
2194
+ if (!payload || typeof payload.sectionId !== "string" || !payload.values || typeof payload.values !== "object") {
2195
+ res.status(400).json({ ok: false, message: "Invalid home action payload" });
2196
+ return;
2197
+ }
2198
+ let result;
2199
+ try {
2200
+ result = await action(context, {
2201
+ sectionId: payload.sectionId,
2202
+ values: payload.values
2203
+ });
2204
+ } catch (error) {
2205
+ const message = error instanceof Error ? error.message : "Home action failed";
2206
+ res.status(500).json({ ok: false, message });
2207
+ return;
2208
+ }
2209
+ res.json(result);
2210
+ });
2211
+ router.get("/api/plugins", ensureAuthenticated, async (req, res) => {
2212
+ const context = createContext(req, options);
2213
+ const activeScope = context.selectedGuildId ? "guild" : "user";
2214
+ const payload = [];
2215
+ for (const plugin of plugins) {
2216
+ const pluginScope = plugin.scope ?? "both";
2217
+ if (pluginScope !== "both" && pluginScope !== activeScope) {
2218
+ continue;
2219
+ }
2220
+ const panels = await plugin.getPanels(context);
2221
+ payload.push({
2222
+ id: plugin.id,
2223
+ name: plugin.name,
2224
+ description: plugin.description,
2225
+ panels
2226
+ });
2227
+ }
2228
+ res.json({ plugins: payload });
2229
+ });
2230
+ router.post("/api/plugins/:pluginId/:actionId", ensureAuthenticated, async (req, res) => {
2231
+ const context = createContext(req, options);
2232
+ const plugin = plugins.find((item) => item.id === req.params.pluginId);
2233
+ if (!plugin) {
2234
+ res.status(404).json({ ok: false, message: "Plugin not found" });
2235
+ return;
2236
+ }
2237
+ const action = plugin.actions?.[req.params.actionId];
2238
+ if (!action) {
2239
+ res.status(404).json({ ok: false, message: "Action not found" });
2240
+ return;
2241
+ }
2242
+ let result;
2243
+ try {
2244
+ result = await action(context, req.body);
2245
+ } catch (error) {
2246
+ const message = error instanceof Error ? error.message : "Plugin action failed";
2247
+ res.status(500).json({ ok: false, message });
2248
+ return;
2249
+ }
2250
+ res.json(result);
2251
+ });
2252
+ app.use(basePath, router);
2253
+ let server;
2254
+ return {
2255
+ app,
2256
+ async start() {
2257
+ if (options.app) {
2258
+ return;
2259
+ }
2260
+ if (server) {
2261
+ return;
2262
+ }
2263
+ const port = options.port ?? 3e3;
2264
+ const host = options.host ?? "0.0.0.0";
2265
+ server = (0, import_node_http.createServer)(app);
2266
+ await new Promise((resolve) => {
2267
+ server.listen(port, host, () => resolve());
2268
+ });
2269
+ },
2270
+ async stop() {
2271
+ if (!server) {
2272
+ return;
2273
+ }
2274
+ await new Promise((resolve, reject) => {
2275
+ server.close((error) => {
2276
+ if (error) {
2277
+ reject(error);
2278
+ return;
2279
+ }
2280
+ resolve();
2281
+ });
2282
+ });
2283
+ server = void 0;
2284
+ }
2285
+ };
2286
+ }
2287
+
2288
+ // src/designer.ts
2289
+ var CategoryBuilder = class {
2290
+ constructor(scope, categoryId, categoryLabel) {
2291
+ this.scope = scope;
2292
+ this.categoryId = categoryId;
2293
+ this.categoryLabel = categoryLabel;
2294
+ }
2295
+ sections = [];
2296
+ section(input) {
2297
+ this.sections.push({
2298
+ id: input.id,
2299
+ title: input.title,
2300
+ description: input.description,
2301
+ width: input.width,
2302
+ fields: input.fields ?? [],
2303
+ actions: input.actions ?? [],
2304
+ scope: this.scope,
2305
+ categoryId: this.categoryId
2306
+ });
2307
+ return this;
2308
+ }
2309
+ buildCategory() {
2310
+ return {
2311
+ id: this.categoryId,
2312
+ label: this.categoryLabel,
2313
+ scope: this.scope
2314
+ };
2315
+ }
2316
+ buildSections() {
2317
+ return [...this.sections];
2318
+ }
2319
+ };
2320
+ var DashboardDesigner = class {
2321
+ partialOptions;
2322
+ categories = [];
2323
+ pages = [];
2324
+ homeActions = {};
2325
+ loadHandlers = {};
2326
+ saveHandlers = {};
2327
+ constructor(baseOptions) {
2328
+ this.partialOptions = { ...baseOptions };
2329
+ }
2330
+ setup(input) {
2331
+ if (input.ownerIds) this.partialOptions.ownerIds = input.ownerIds;
2332
+ if (input.botInvitePermissions) this.partialOptions.botInvitePermissions = input.botInvitePermissions;
2333
+ if (input.botInviteScopes) this.partialOptions.botInviteScopes = input.botInviteScopes;
2334
+ if (input.dashboardName) this.partialOptions.dashboardName = input.dashboardName;
2335
+ if (input.basePath) this.partialOptions.basePath = input.basePath;
2336
+ if (input.uiTemplate) this.partialOptions.uiTemplate = input.uiTemplate;
2337
+ return this;
2338
+ }
2339
+ useTemplate(templateId) {
2340
+ this.partialOptions.uiTemplate = templateId;
2341
+ return this;
2342
+ }
2343
+ addTemplate(templateId, renderer) {
2344
+ this.partialOptions.uiTemplates = {
2345
+ ...this.partialOptions.uiTemplates ?? {},
2346
+ [templateId]: renderer
2347
+ };
2348
+ return this;
2349
+ }
2350
+ setupDesign(input) {
2351
+ this.partialOptions.setupDesign = {
2352
+ ...this.partialOptions.setupDesign ?? {},
2353
+ ...input
2354
+ };
2355
+ return this;
2356
+ }
2357
+ userCategory(categoryId, categoryLabel, build) {
2358
+ const builder = new CategoryBuilder("user", categoryId, categoryLabel);
2359
+ build(builder);
2360
+ this.categories.push(builder);
2361
+ return this;
2362
+ }
2363
+ guildCategory(categoryId, categoryLabel, build) {
2364
+ const builder = new CategoryBuilder("guild", categoryId, categoryLabel);
2365
+ build(builder);
2366
+ this.categories.push(builder);
2367
+ return this;
2368
+ }
2369
+ setupCategory(categoryId, categoryLabel, build) {
2370
+ const builder = new CategoryBuilder("setup", categoryId, categoryLabel);
2371
+ build(builder);
2372
+ this.categories.push(builder);
2373
+ return this;
2374
+ }
2375
+ setupPage(input) {
2376
+ const scope = input.scope ?? "user";
2377
+ const categoryId = input.categoryId ?? input.id;
2378
+ this.pages.push({
2379
+ pageId: input.id,
2380
+ category: {
2381
+ id: categoryId,
2382
+ label: input.label ?? input.title,
2383
+ scope
2384
+ },
2385
+ section: {
2386
+ id: input.id,
2387
+ title: input.title,
2388
+ description: input.description,
2389
+ width: input.width,
2390
+ scope,
2391
+ categoryId,
2392
+ fields: input.fields ?? [],
2393
+ actions: input.actions ?? []
2394
+ }
2395
+ });
2396
+ return this;
2397
+ }
2398
+ onHomeAction(actionId, handler) {
2399
+ this.homeActions[actionId] = handler;
2400
+ return this;
2401
+ }
2402
+ onLoad(pageId, handler) {
2403
+ this.loadHandlers[pageId] = handler;
2404
+ return this;
2405
+ }
2406
+ onload(pageId, handler) {
2407
+ return this.onLoad(pageId, handler);
2408
+ }
2409
+ onSave(pageId, handler) {
2410
+ this.saveHandlers[pageId] = handler;
2411
+ return this;
2412
+ }
2413
+ onsave(pageId, handler) {
2414
+ return this.onSave(pageId, handler);
2415
+ }
2416
+ build() {
2417
+ const staticCategories = this.categories.map((item) => item.buildCategory());
2418
+ const staticSections = this.categories.flatMap((item) => item.buildSections());
2419
+ const pageCategories = this.pages.map((item) => item.category);
2420
+ const baseSections = [...staticSections, ...this.pages.map((item) => item.section)];
2421
+ const categoryMap = /* @__PURE__ */ new Map();
2422
+ for (const category of [...staticCategories, ...pageCategories]) {
2423
+ const key = `${category.scope}:${category.id}`;
2424
+ if (!categoryMap.has(key)) {
2425
+ categoryMap.set(key, category);
2426
+ }
2427
+ }
2428
+ const categories = [...categoryMap.values()];
2429
+ const saveActionIds = {};
2430
+ for (const section of baseSections) {
2431
+ if (this.saveHandlers[section.id]) {
2432
+ saveActionIds[section.id] = `save:${section.id}`;
2433
+ }
2434
+ }
2435
+ const resolvedActions = {
2436
+ ...this.homeActions
2437
+ };
2438
+ for (const [sectionId, handler] of Object.entries(this.saveHandlers)) {
2439
+ resolvedActions[saveActionIds[sectionId]] = handler;
2440
+ }
2441
+ const home = {
2442
+ getCategories: () => categories,
2443
+ getSections: async (context) => {
2444
+ const sections = [];
2445
+ for (const sourceSection of baseSections) {
2446
+ let section = {
2447
+ ...sourceSection,
2448
+ fields: sourceSection.fields ? [...sourceSection.fields] : [],
2449
+ actions: sourceSection.actions ? [...sourceSection.actions] : []
2450
+ };
2451
+ const saveActionId = saveActionIds[section.id];
2452
+ if (saveActionId && !section.actions?.some((action) => action.id === saveActionId)) {
2453
+ section.actions = [...section.actions ?? [], {
2454
+ id: saveActionId,
2455
+ label: "Save",
2456
+ variant: "primary"
2457
+ }];
2458
+ }
2459
+ const loadHandler = this.loadHandlers[section.id];
2460
+ if (loadHandler) {
2461
+ const loaded = await loadHandler(context, section);
2462
+ if (loaded) {
2463
+ section = {
2464
+ ...section,
2465
+ ...loaded,
2466
+ fields: loaded.fields ?? section.fields,
2467
+ actions: loaded.actions ?? section.actions
2468
+ };
2469
+ }
2470
+ }
2471
+ sections.push(section);
2472
+ }
2473
+ return sections;
2474
+ },
2475
+ actions: resolvedActions
2476
+ };
2477
+ return {
2478
+ ...this.partialOptions,
2479
+ home
2480
+ };
2481
+ }
2482
+ };
2483
+ function createDashboardDesigner(baseOptions) {
2484
+ return new DashboardDesigner(baseOptions);
2485
+ }
2486
+ // Annotate the CommonJS export names for ESM import in node:
2487
+ 0 && (module.exports = {
2488
+ DashboardDesigner,
2489
+ builtinTemplateRenderers,
2490
+ createDashboard,
2491
+ createDashboardDesigner,
2492
+ createDiscordHelpers,
2493
+ getBuiltinTemplateRenderer
2494
+ });
2495
+ //# sourceMappingURL=index.cjs.map