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