@developer.krd/discord-dashboard 0.1.3 → 0.1.7

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