@developer.krd/discord-dashboard 0.1.3 → 0.1.5

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