@developer.krd/discord-dashboard 0.1.2 → 0.1.5

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