ui: modernize WebUI and add managed themes
This commit is contained in:
parent
c8208b78b7
commit
15bcd53c99
@ -5,7 +5,8 @@
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/main.js",
|
||||
"run": "node run.js"
|
||||
"run": "node run.js",
|
||||
"verify:webui": "node scripts/verify-webui.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@ -4,11 +4,15 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
||||
<%- include("../../../src/web/views/partials/theme-vars", { theme }) %>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card" style="max-width: 720px; width: 100%;">
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>">
|
||||
<main class="standalone-page">
|
||||
<div class="card standalone-card">
|
||||
<h1>Access restricted</h1>
|
||||
<p class="hint">Your account is currently restricted by moderation.</p>
|
||||
<div class="stat-grid">
|
||||
@ -29,7 +33,7 @@
|
||||
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="card standalone-detail">
|
||||
<h2>Summary</h2>
|
||||
<p><%= sanction.reason_short %></p>
|
||||
<h2>Details</h2>
|
||||
@ -37,6 +41,6 @@
|
||||
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
123
scripts/verify-webui.js
Normal file
123
scripts/verify-webui.js
Normal file
@ -0,0 +1,123 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ejs = require("ejs");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
|
||||
function listFiles(directory, extension, output = []) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) listFiles(target, extension, output);
|
||||
else if (target.endsWith(extension)) output.push(target);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function verifyViews() {
|
||||
const viewRoots = [path.join(root, "src", "web", "views"), path.join(root, "plugins")];
|
||||
const files = viewRoots.flatMap((directory) => listFiles(directory, ".ejs"));
|
||||
for (const file of files) {
|
||||
ejs.compile(fs.readFileSync(file, "utf8"), { filename: file });
|
||||
}
|
||||
return files.length;
|
||||
}
|
||||
|
||||
function verifyThemeService() {
|
||||
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-theme-test-"));
|
||||
const serviceDir = path.join(sandbox, "src", "services");
|
||||
let database = null;
|
||||
fs.mkdirSync(serviceDir, { recursive: true });
|
||||
for (const file of ["config.js", "db.js", "settings.js", "themes.js"]) {
|
||||
fs.copyFileSync(
|
||||
path.join(root, "src", "services", file),
|
||||
path.join(serviceDir, file)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
database = require(path.join(serviceDir, "db.js"));
|
||||
database.migrate();
|
||||
require(path.join(serviceDir, "settings.js")).ensureDefaults();
|
||||
const themes = require(path.join(serviceDir, "themes.js"));
|
||||
|
||||
assert.strictEqual(themes.BUILTIN_THEMES.length, 6);
|
||||
for (const theme of themes.BUILTIN_THEMES) {
|
||||
assert.deepStrictEqual(themes.validateThemeValues(theme), []);
|
||||
}
|
||||
|
||||
const library = themes.listThemes();
|
||||
assert(library.every((theme) => theme.builtin));
|
||||
assert.throws(
|
||||
() => themes.saveCustomTheme("builtin:lumi-default", library[0]),
|
||||
/read-only/
|
||||
);
|
||||
|
||||
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
|
||||
assert.strictEqual(copy.builtin, false);
|
||||
themes.setActiveTheme(copy.id);
|
||||
assert.strictEqual(themes.getActiveTheme().id, copy.id);
|
||||
|
||||
const invalid = JSON.parse(JSON.stringify(copy));
|
||||
invalid.light.text = "not-a-color";
|
||||
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex color/);
|
||||
|
||||
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
|
||||
assert.strictEqual(renamed.name, "Verified Theme");
|
||||
|
||||
const repaired = themes.normalizeThemeValues({
|
||||
...renamed,
|
||||
light: { ...renamed.light, text: "#ffffff", surface: "#ffffff" }
|
||||
});
|
||||
assert.notStrictEqual(repaired.light.text, repaired.light.surface);
|
||||
|
||||
const themeView = path.join(root, "src", "web", "views", "admin-theme.ejs");
|
||||
const rendered = ejs.render(fs.readFileSync(themeView, "utf8"), {
|
||||
title: "Theming",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
activeTheme: renamed,
|
||||
themes: themes.listThemes(),
|
||||
editingTheme: renamed,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: { username: "Admin" },
|
||||
userAvatar: null,
|
||||
userInitial: "A",
|
||||
platformLogins: [],
|
||||
flash: null,
|
||||
softError: null
|
||||
}, { filename: themeView });
|
||||
assert(rendered.includes("data-theme-editor"));
|
||||
assert(rendered.includes("Built-in · read-only"));
|
||||
|
||||
const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
|
||||
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
|
||||
title: "Access restricted",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
sanction: {
|
||||
action_type: "timeout",
|
||||
status: "active",
|
||||
created_at: Date.now(),
|
||||
expires_at: Date.now() + 60000,
|
||||
reason_short: "Verification",
|
||||
reason_detail: "Standalone themed view verification.",
|
||||
created_by_name: "Admin"
|
||||
}
|
||||
}, { filename: statusView });
|
||||
assert(statusRendered.includes("/lumi-components.css"));
|
||||
assert(statusRendered.includes('data-theme-id="custom:'));
|
||||
|
||||
themes.deleteCustomTheme(copy.id);
|
||||
assert.strictEqual(themes.getActiveTheme().id, themes.DEFAULT_THEME_ID);
|
||||
} finally {
|
||||
database?.db.close();
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const viewCount = verifyViews();
|
||||
verifyThemeService();
|
||||
console.log(`WebUI verification passed: ${viewCount} EJS views and theme CRUD.`);
|
||||
@ -18,6 +18,15 @@ function migrate() {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_theme_id TEXT NOT NULL,
|
||||
values_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
654
src/services/themes.js
Normal file
654
src/services/themes.js
Normal file
@ -0,0 +1,654 @@
|
||||
const crypto = require("crypto");
|
||||
const { db } = require("./db");
|
||||
const { getSetting, setSetting } = require("./settings");
|
||||
|
||||
const DEFAULT_THEME_ID = "builtin:lumi-default";
|
||||
const THEME_SYSTEM_VERSION = 1;
|
||||
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
const MODE_COLOR_FIELDS = [
|
||||
"bg1",
|
||||
"bg2",
|
||||
"bg3",
|
||||
"text",
|
||||
"muted",
|
||||
"accent",
|
||||
"accentAlt",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"info",
|
||||
"surface",
|
||||
"surface2",
|
||||
"surface3",
|
||||
"border",
|
||||
"link",
|
||||
"buttonBg",
|
||||
"buttonText",
|
||||
"buttonHover",
|
||||
"inputBg",
|
||||
"inputBorder",
|
||||
"inputText",
|
||||
"focusRing"
|
||||
];
|
||||
|
||||
const ROLE_COLOR_FIELDS = ["public", "mod", "admin"];
|
||||
|
||||
const DEFAULT_MODE_LIGHT = {
|
||||
bg1: "#dff4f2",
|
||||
bg2: "#f5f7f8",
|
||||
bg3: "#fff1dc",
|
||||
text: "#182026",
|
||||
muted: "#5a6872",
|
||||
accent: "#176b75",
|
||||
accentAlt: "#e58b2b",
|
||||
success: "#23845b",
|
||||
warning: "#a96612",
|
||||
danger: "#bd4d4d",
|
||||
info: "#3479a8",
|
||||
surface: "#ffffff",
|
||||
surface2: "#f4f7f8",
|
||||
surface3: "#edf2f3",
|
||||
border: "#d8e0e3",
|
||||
link: "#0d6470",
|
||||
buttonBg: "#176b75",
|
||||
buttonText: "#ffffff",
|
||||
buttonHover: "#0f5660",
|
||||
inputBg: "#ffffff",
|
||||
inputBorder: "#c8d3d7",
|
||||
inputText: "#182026",
|
||||
focusRing: "#2f98a5"
|
||||
};
|
||||
|
||||
const DEFAULT_MODE_DARK = {
|
||||
bg1: "#102c31",
|
||||
bg2: "#11171b",
|
||||
bg3: "#261e18",
|
||||
text: "#f2f6f7",
|
||||
muted: "#aebbc1",
|
||||
accent: "#63c4cf",
|
||||
accentAlt: "#f0b45f",
|
||||
success: "#59c894",
|
||||
warning: "#e4b35d",
|
||||
danger: "#ef7b78",
|
||||
info: "#74b9e6",
|
||||
surface: "#1a2227",
|
||||
surface2: "#202b31",
|
||||
surface3: "#27343b",
|
||||
border: "#35434a",
|
||||
link: "#7bd3dc",
|
||||
buttonBg: "#4dafba",
|
||||
buttonText: "#08191c",
|
||||
buttonHover: "#68c8d2",
|
||||
inputBg: "#151d21",
|
||||
inputBorder: "#42535b",
|
||||
inputText: "#f2f6f7",
|
||||
focusRing: "#7bd3dc"
|
||||
};
|
||||
|
||||
const DEFAULT_THEME_VALUES = {
|
||||
light: DEFAULT_MODE_LIGHT,
|
||||
dark: DEFAULT_MODE_DARK,
|
||||
role: {
|
||||
public: "#ffffff",
|
||||
mod: "#23845b",
|
||||
admin: "#bd4d6d"
|
||||
},
|
||||
metrics: {
|
||||
radius: 14,
|
||||
shadowStrength: 0.14,
|
||||
spacingScale: 1
|
||||
}
|
||||
};
|
||||
|
||||
function mergeMode(base, override = {}) {
|
||||
return Object.fromEntries(
|
||||
MODE_COLOR_FIELDS.map((field) => [field, override[field] || base[field]])
|
||||
);
|
||||
}
|
||||
|
||||
function createBuiltin(id, name, description, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id: `builtin:${id}`,
|
||||
name,
|
||||
description,
|
||||
builtin: true,
|
||||
readOnly: true,
|
||||
baseThemeId: null,
|
||||
light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light),
|
||||
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
|
||||
role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) },
|
||||
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) }
|
||||
});
|
||||
}
|
||||
|
||||
const BUILTIN_THEMES = [
|
||||
createBuiltin(
|
||||
"lumi-default",
|
||||
"Lumi Default",
|
||||
"Balanced teal and warm accents with automatic light and dark modes."
|
||||
),
|
||||
createBuiltin("lumi-dark", "Lumi Dark", "A deep, low-glare theme for dark workspaces.", {
|
||||
light: {
|
||||
bg1: "#18242a",
|
||||
bg2: "#11171b",
|
||||
bg3: "#241d19",
|
||||
text: "#f3f6f7",
|
||||
muted: "#b4c0c5",
|
||||
surface: "#1c252a",
|
||||
surface2: "#222d33",
|
||||
surface3: "#29363d",
|
||||
border: "#3b4a52",
|
||||
inputBg: "#141c20",
|
||||
inputBorder: "#465860",
|
||||
inputText: "#f3f6f7",
|
||||
accent: "#67c6d0",
|
||||
link: "#7bd3dc",
|
||||
buttonBg: "#51b4bf",
|
||||
buttonText: "#08191c",
|
||||
buttonHover: "#71d0da",
|
||||
focusRing: "#7bd3dc"
|
||||
}
|
||||
}),
|
||||
createBuiltin("lumi-light", "Lumi Light", "A crisp, bright theme with restrained shadows.", {
|
||||
dark: DEFAULT_MODE_LIGHT,
|
||||
metrics: { shadowStrength: 0.08 }
|
||||
}),
|
||||
createBuiltin("high-contrast", "High Contrast", "Maximum clarity with strong focus and status colors.", {
|
||||
light: {
|
||||
bg1: "#ffffff",
|
||||
bg2: "#ffffff",
|
||||
bg3: "#f2f2f2",
|
||||
text: "#000000",
|
||||
muted: "#303030",
|
||||
accent: "#004f5a",
|
||||
accentAlt: "#8a4300",
|
||||
success: "#006b3c",
|
||||
warning: "#7a4700",
|
||||
danger: "#a00000",
|
||||
info: "#004b88",
|
||||
surface: "#ffffff",
|
||||
surface2: "#f5f5f5",
|
||||
surface3: "#e8e8e8",
|
||||
border: "#555555",
|
||||
link: "#003f99",
|
||||
buttonBg: "#003f49",
|
||||
buttonText: "#ffffff",
|
||||
buttonHover: "#002c33",
|
||||
inputBg: "#ffffff",
|
||||
inputBorder: "#333333",
|
||||
inputText: "#000000",
|
||||
focusRing: "#005fcc"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#000000",
|
||||
bg2: "#000000",
|
||||
bg3: "#101010",
|
||||
text: "#ffffff",
|
||||
muted: "#d6d6d6",
|
||||
accent: "#67e8f9",
|
||||
accentAlt: "#ffd166",
|
||||
success: "#65e6a3",
|
||||
warning: "#ffd166",
|
||||
danger: "#ff8c8c",
|
||||
info: "#8fd3ff",
|
||||
surface: "#080808",
|
||||
surface2: "#151515",
|
||||
surface3: "#222222",
|
||||
border: "#aaaaaa",
|
||||
link: "#8fd3ff",
|
||||
buttonBg: "#a5f3fc",
|
||||
buttonText: "#000000",
|
||||
buttonHover: "#ffffff",
|
||||
inputBg: "#000000",
|
||||
inputBorder: "#dddddd",
|
||||
inputText: "#ffffff",
|
||||
focusRing: "#ffffff"
|
||||
},
|
||||
metrics: { radius: 8, shadowStrength: 0, spacingScale: 1.05 }
|
||||
}),
|
||||
createBuiltin("midnight", "Midnight", "Cool blue surfaces with violet highlights.", {
|
||||
light: {
|
||||
bg1: "#dce8ff",
|
||||
bg2: "#f4f6fb",
|
||||
bg3: "#eee8ff",
|
||||
accent: "#4457a6",
|
||||
accentAlt: "#8258b7",
|
||||
link: "#354a9b",
|
||||
buttonBg: "#4457a6",
|
||||
buttonHover: "#34448a",
|
||||
focusRing: "#6f82d8"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#10182f",
|
||||
bg2: "#0b1020",
|
||||
bg3: "#211630",
|
||||
surface: "#141c32",
|
||||
surface2: "#19233d",
|
||||
surface3: "#202c49",
|
||||
border: "#334160",
|
||||
accent: "#91a4ff",
|
||||
accentAlt: "#c49aff",
|
||||
link: "#aab8ff",
|
||||
buttonBg: "#91a4ff",
|
||||
buttonText: "#0b1020",
|
||||
buttonHover: "#b0bcff",
|
||||
focusRing: "#c49aff"
|
||||
}
|
||||
}),
|
||||
createBuiltin("soft-aurora", "Soft Aurora", "A gentle mint, lavender, and coral palette.", {
|
||||
light: {
|
||||
bg1: "#dcf8ee",
|
||||
bg2: "#f8f5fb",
|
||||
bg3: "#ffe9e4",
|
||||
accent: "#397f70",
|
||||
accentAlt: "#986aa8",
|
||||
link: "#306f63",
|
||||
buttonBg: "#397f70",
|
||||
buttonHover: "#2d665a",
|
||||
focusRing: "#8a6fa8"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#17352f",
|
||||
bg2: "#171a22",
|
||||
bg3: "#38242e",
|
||||
surface: "#20262d",
|
||||
surface2: "#283038",
|
||||
surface3: "#313b44",
|
||||
border: "#43505a",
|
||||
accent: "#82d7c1",
|
||||
accentAlt: "#d4a7e1",
|
||||
link: "#9ce7d4",
|
||||
buttonBg: "#72c9b3",
|
||||
buttonText: "#10231f",
|
||||
buttonHover: "#96e3d0",
|
||||
focusRing: "#d4a7e1"
|
||||
},
|
||||
metrics: { radius: 18, shadowStrength: 0.1, spacingScale: 1.05 }
|
||||
})
|
||||
];
|
||||
|
||||
const BUILTIN_MAP = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||
|
||||
function cloneTheme(theme) {
|
||||
return JSON.parse(JSON.stringify(theme));
|
||||
}
|
||||
|
||||
function getBuiltinTheme(id = DEFAULT_THEME_ID) {
|
||||
return BUILTIN_MAP.get(id) || BUILTIN_MAP.get(DEFAULT_THEME_ID);
|
||||
}
|
||||
|
||||
function customKey(id) {
|
||||
return `custom:${id}`;
|
||||
}
|
||||
|
||||
function customId(themeId) {
|
||||
return String(themeId || "").startsWith("custom:")
|
||||
? String(themeId).slice("custom:".length)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
|
||||
const source = values && typeof values === "object" ? values : {};
|
||||
const normalized = {
|
||||
light: mergeMode(baseTheme.light, source.light),
|
||||
dark: mergeMode(baseTheme.dark, source.dark),
|
||||
role: { ...baseTheme.role, ...(source.role || {}) },
|
||||
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) }
|
||||
};
|
||||
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(normalized[mode][field])) {
|
||||
normalized[mode][field] = baseTheme[mode][field];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(normalized.role[field])) {
|
||||
normalized.role[field] = baseTheme.role[field];
|
||||
}
|
||||
}
|
||||
normalized.metrics.radius = clampNumber(
|
||||
normalized.metrics.radius,
|
||||
0,
|
||||
32,
|
||||
baseTheme.metrics.radius
|
||||
);
|
||||
normalized.metrics.shadowStrength = clampNumber(
|
||||
normalized.metrics.shadowStrength,
|
||||
0,
|
||||
0.35,
|
||||
baseTheme.metrics.shadowStrength
|
||||
);
|
||||
normalized.metrics.spacingScale = clampNumber(
|
||||
normalized.metrics.spacingScale,
|
||||
0.75,
|
||||
1.35,
|
||||
baseTheme.metrics.spacingScale
|
||||
);
|
||||
for (const mode of ["light", "dark"]) {
|
||||
if (contrastRatio(normalized[mode].text, normalized[mode].surface) < 4.5) {
|
||||
normalized[mode].text = baseTheme[mode].text;
|
||||
normalized[mode].surface = baseTheme[mode].surface;
|
||||
}
|
||||
if (contrastRatio(normalized[mode].buttonText, normalized[mode].buttonBg) < 4.5) {
|
||||
normalized[mode].buttonText = baseTheme[mode].buttonText;
|
||||
normalized[mode].buttonBg = baseTheme[mode].buttonBg;
|
||||
}
|
||||
if (contrastRatio(normalized[mode].inputText, normalized[mode].inputBg) < 4.5) {
|
||||
normalized[mode].inputText = baseTheme[mode].inputText;
|
||||
normalized[mode].inputBg = baseTheme[mode].inputBg;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function parseHex(value) {
|
||||
const raw = String(value || "").slice(1);
|
||||
return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16));
|
||||
}
|
||||
|
||||
function relativeLuminance(value) {
|
||||
const channels = parseHex(value).map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: Math.pow((normalized + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(left, right) {
|
||||
const a = relativeLuminance(left);
|
||||
const b = relativeLuminance(right);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function validateThemeValues(values) {
|
||||
const errors = [];
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(String(values?.[mode]?.[field] || ""))) {
|
||||
errors.push(`${mode}.${field} must be a six-digit hex color.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
if (!COLOR_PATTERN.test(String(values?.role?.[field] || ""))) {
|
||||
errors.push(`role.${field} must be a six-digit hex color.`);
|
||||
}
|
||||
}
|
||||
|
||||
const metricRules = [
|
||||
["radius", 0, 32],
|
||||
["shadowStrength", 0, 0.35],
|
||||
["spacingScale", 0.75, 1.35]
|
||||
];
|
||||
for (const [field, min, max] of metricRules) {
|
||||
const value = Number(values?.metrics?.[field]);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
errors.push(`metrics.${field} must be between ${min} and ${max}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.length) {
|
||||
for (const mode of ["light", "dark"]) {
|
||||
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
|
||||
errors.push(`${mode} text and surface colors need at least 4.5:1 contrast.`);
|
||||
}
|
||||
if (contrastRatio(values[mode].buttonText, values[mode].buttonBg) < 4.5) {
|
||||
errors.push(`${mode} button text and background need at least 4.5:1 contrast.`);
|
||||
}
|
||||
if (contrastRatio(values[mode].inputText, values[mode].inputBg) < 4.5) {
|
||||
errors.push(`${mode} input text and background need at least 4.5:1 contrast.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function legacyThemeValues() {
|
||||
const legacy = {
|
||||
light: {
|
||||
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
|
||||
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
|
||||
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
|
||||
text: getSetting("theme_light_text", "#121518"),
|
||||
muted: getSetting("theme_light_text_muted", "#2c3137"),
|
||||
accent: getSetting("theme_light_accent", "#0f6a78"),
|
||||
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
|
||||
danger: getSetting("theme_light_danger", "#d66d5c"),
|
||||
surface: getSetting("theme_light_surface", "#ffffff"),
|
||||
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
|
||||
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
|
||||
border: getSetting("theme_light_border", "#e3ddd6")
|
||||
},
|
||||
dark: {
|
||||
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
|
||||
bg2: getSetting("theme_dark_bg_2", "#16181b"),
|
||||
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
|
||||
text: getSetting("theme_dark_text", "#f2f0ec"),
|
||||
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
|
||||
accent: getSetting("theme_dark_accent", "#4fb6c2"),
|
||||
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
|
||||
danger: getSetting("theme_dark_danger", "#e08173"),
|
||||
surface: getSetting("theme_dark_surface", "#232629"),
|
||||
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
|
||||
surface3: getSetting("theme_dark_surface_3", "#30353a"),
|
||||
border: getSetting("theme_dark_border", "#34393d")
|
||||
},
|
||||
role: {
|
||||
public: getSetting("theme_role_public", "#ffffff"),
|
||||
mod: getSetting("theme_role_mod", "#2cb678"),
|
||||
admin: getSetting("theme_role_admin", "#e35678")
|
||||
}
|
||||
};
|
||||
return normalizeThemeValues(legacy, getBuiltinTheme());
|
||||
}
|
||||
|
||||
function legacyWasCustomized(values) {
|
||||
const defaults = {
|
||||
light: {
|
||||
bg1: "#ffe5c4", bg2: "#f4efe8", bg3: "#e9f3f1", text: "#121518",
|
||||
muted: "#2c3137", accent: "#0f6a78", accentAlt: "#f4a340",
|
||||
danger: "#d66d5c", surface: "#ffffff", surface2: "#fbf9f6",
|
||||
surface3: "#f9f5ef", border: "#e3ddd6"
|
||||
},
|
||||
dark: {
|
||||
bg1: "#1b1d1f", bg2: "#16181b", bg3: "#0f1113", text: "#f2f0ec",
|
||||
muted: "#c5bfb7", accent: "#4fb6c2", accentAlt: "#f1b765",
|
||||
danger: "#e08173", surface: "#232629", surface2: "#2b2f33",
|
||||
surface3: "#30353a", border: "#34393d"
|
||||
},
|
||||
role: { public: "#ffffff", mod: "#2cb678", admin: "#e35678" }
|
||||
};
|
||||
return ["light", "dark", "role"].some((group) =>
|
||||
Object.entries(defaults[group]).some(([key, value]) => values[group][key] !== value)
|
||||
);
|
||||
}
|
||||
|
||||
function ensureThemeMigration() {
|
||||
if (Number(getSetting("theme_system_version", 0)) >= THEME_SYSTEM_VERSION) return;
|
||||
const legacy = legacyThemeValues();
|
||||
setSetting("theme_system_version", THEME_SYSTEM_VERSION);
|
||||
if (legacyWasCustomized(legacy)) {
|
||||
const theme = insertCustomTheme("Migrated Theme", DEFAULT_THEME_ID, legacy);
|
||||
setSetting("theme_active_id", theme.id);
|
||||
} else {
|
||||
setSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function rowToTheme(row) {
|
||||
const base = getBuiltinTheme(row.base_theme_id);
|
||||
let stored = {};
|
||||
try {
|
||||
stored = JSON.parse(row.values_json);
|
||||
} catch {
|
||||
stored = {};
|
||||
}
|
||||
const values = normalizeThemeValues(stored, base);
|
||||
return {
|
||||
id: customKey(row.id),
|
||||
name: row.name,
|
||||
description: `Custom theme based on ${base.name}.`,
|
||||
builtin: false,
|
||||
readOnly: false,
|
||||
baseThemeId: base.id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
...values
|
||||
};
|
||||
}
|
||||
|
||||
function getThemeById(themeId) {
|
||||
ensureThemeMigration();
|
||||
if (BUILTIN_MAP.has(themeId)) return cloneTheme(BUILTIN_MAP.get(themeId));
|
||||
const id = customId(themeId);
|
||||
if (!id) return null;
|
||||
const row = db.prepare("SELECT * FROM custom_themes WHERE id = ?").get(id);
|
||||
return row ? rowToTheme(row) : null;
|
||||
}
|
||||
|
||||
function listThemes() {
|
||||
ensureThemeMigration();
|
||||
const custom = db
|
||||
.prepare("SELECT * FROM custom_themes ORDER BY lower(name), created_at")
|
||||
.all()
|
||||
.map(rowToTheme);
|
||||
return [...BUILTIN_THEMES.map(cloneTheme), ...custom];
|
||||
}
|
||||
|
||||
function getActiveTheme() {
|
||||
ensureThemeMigration();
|
||||
const requested = getSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
const theme = getThemeById(requested) || cloneTheme(getBuiltinTheme());
|
||||
if (theme.id !== requested) setSetting("theme_active_id", theme.id);
|
||||
return theme;
|
||||
}
|
||||
|
||||
function setActiveTheme(themeId) {
|
||||
const theme = getThemeById(themeId);
|
||||
if (!theme) throw new Error("Theme not found.");
|
||||
setSetting("theme_active_id", theme.id);
|
||||
return theme;
|
||||
}
|
||||
|
||||
function cleanName(value) {
|
||||
const name = String(value || "").trim().replace(/\s+/g, " ");
|
||||
if (name.length < 2 || name.length > 60) {
|
||||
throw new Error("Theme name must be between 2 and 60 characters.");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function insertCustomTheme(name, baseThemeId, values) {
|
||||
const clean = cleanName(name);
|
||||
const base = getBuiltinTheme(baseThemeId);
|
||||
const normalized = normalizeThemeValues(values, base);
|
||||
const errors = validateThemeValues(normalized);
|
||||
if (errors.length) throw new Error(errors[0]);
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT INTO custom_themes (id, name, base_theme_id, values_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).run(id, clean, base.id, JSON.stringify(normalized), now, now);
|
||||
return getThemeById(customKey(id));
|
||||
}
|
||||
|
||||
function duplicateTheme(themeId, name) {
|
||||
const source = getThemeById(themeId);
|
||||
if (!source) throw new Error("Theme not found.");
|
||||
return insertCustomTheme(
|
||||
name || `${source.name} Copy`,
|
||||
source.builtin ? source.id : source.baseThemeId,
|
||||
source
|
||||
);
|
||||
}
|
||||
|
||||
function saveCustomTheme(themeId, values) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes are read-only.");
|
||||
const current = getThemeById(themeId);
|
||||
if (!current) throw new Error("Theme not found.");
|
||||
const normalized = normalizeThemeValues(values, getBuiltinTheme(current.baseThemeId));
|
||||
const errors = validateThemeValues(values);
|
||||
if (errors.length) {
|
||||
const error = new Error(errors[0]);
|
||||
error.validationErrors = errors;
|
||||
throw error;
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE custom_themes SET values_json = ?, updated_at = ? WHERE id = ?"
|
||||
).run(JSON.stringify(normalized), Date.now(), id);
|
||||
return getThemeById(themeId);
|
||||
}
|
||||
|
||||
function renameCustomTheme(themeId, name) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes cannot be renamed.");
|
||||
const result = db
|
||||
.prepare("UPDATE custom_themes SET name = ?, updated_at = ? WHERE id = ?")
|
||||
.run(cleanName(name), Date.now(), id);
|
||||
if (!result.changes) throw new Error("Theme not found.");
|
||||
return getThemeById(themeId);
|
||||
}
|
||||
|
||||
function deleteCustomTheme(themeId) {
|
||||
const id = customId(themeId);
|
||||
if (!id) throw new Error("Built-in themes cannot be deleted.");
|
||||
const activeId = getSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
const result = db.prepare("DELETE FROM custom_themes WHERE id = ?").run(id);
|
||||
if (!result.changes) throw new Error("Theme not found.");
|
||||
if (activeId === themeId) setSetting("theme_active_id", DEFAULT_THEME_ID);
|
||||
}
|
||||
|
||||
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
||||
const values = { light: {}, dark: {}, role: {}, metrics: {} };
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
values[mode][field] = String(
|
||||
body?.[`${mode}_${field}`] ?? fallbackTheme[mode][field]
|
||||
).trim();
|
||||
}
|
||||
}
|
||||
for (const field of ROLE_COLOR_FIELDS) {
|
||||
values.role[field] = String(
|
||||
body?.[`role_${field}`] ?? fallbackTheme.role[field]
|
||||
).trim();
|
||||
}
|
||||
values.metrics.radius = Number(body?.metrics_radius ?? fallbackTheme.metrics.radius);
|
||||
values.metrics.shadowStrength = Number(
|
||||
body?.metrics_shadowStrength ?? fallbackTheme.metrics.shadowStrength
|
||||
);
|
||||
values.metrics.spacingScale = Number(
|
||||
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_THEME_ID,
|
||||
MODE_COLOR_FIELDS,
|
||||
ROLE_COLOR_FIELDS,
|
||||
contrastRatio,
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
getActiveTheme,
|
||||
getThemeById,
|
||||
listThemes,
|
||||
normalizeThemeValues,
|
||||
renameCustomTheme,
|
||||
saveCustomTheme,
|
||||
setActiveTheme,
|
||||
validateThemeValues,
|
||||
valuesFromRequest
|
||||
};
|
||||
@ -1,6 +1,11 @@
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const media = window.matchMedia("(max-width: 900px)");
|
||||
const sidebarPreferenceKey = "lumi-sidebar-collapsed";
|
||||
|
||||
if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@ -8,10 +13,18 @@
|
||||
body.classList.toggle("sidebar-open");
|
||||
} else {
|
||||
body.classList.toggle("sidebar-collapsed");
|
||||
window.localStorage.setItem(
|
||||
sidebarPreferenceKey,
|
||||
body.classList.contains("sidebar-collapsed") ? "true" : "false"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
});
|
||||
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (body.classList.contains("sidebar-open")) {
|
||||
@ -20,6 +33,15 @@
|
||||
});
|
||||
});
|
||||
|
||||
media.addEventListener?.("change", () => {
|
||||
body.classList.remove("sidebar-open");
|
||||
if (media.matches) {
|
||||
body.classList.remove("sidebar-collapsed");
|
||||
} else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") {
|
||||
body.classList.add("sidebar-collapsed");
|
||||
}
|
||||
});
|
||||
|
||||
const editToggles = Array.from(
|
||||
document.querySelectorAll("[data-edit-toggle]")
|
||||
);
|
||||
|
||||
549
src/web/public/lumi-components.css
Normal file
549
src/web/public/lumi-components.css
Normal file
@ -0,0 +1,549 @@
|
||||
::selection {
|
||||
color: var(--lumi-button-text);
|
||||
background: var(--lumi-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--lumi-focus) 78%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0;
|
||||
color: var(--lumi-text);
|
||||
font-family: var(--lumi-font-display);
|
||||
line-height: 1.18;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.55rem);
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--lumi-space-1);
|
||||
color: var(--lumi-primary);
|
||||
font: 700 0.75rem/1 var(--lumi-font-display);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--lumi-link);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: var(--lumi-font-mono);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: clamp(1.5rem, 5vw, 3.5rem);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-primary) 25%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-lg);
|
||||
background:
|
||||
linear-gradient(125deg, color-mix(in srgb, var(--lumi-primary) 18%, transparent), transparent 56%),
|
||||
linear-gradient(310deg, color-mix(in srgb, var(--lumi-accent) 17%, transparent), transparent 50%),
|
||||
var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
right: -6rem;
|
||||
top: -7rem;
|
||||
border: 2.5rem solid color-mix(in srgb, var(--lumi-primary) 10%, transparent);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 18ch;
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.hero > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel,
|
||||
.lumi-panel {
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 97%, transparent);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.grid .card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card .card {
|
||||
background: var(--lumi-surface-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card > :last-child,
|
||||
.panel > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
section.card:has(> table.table) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.button,
|
||||
button.button,
|
||||
input[type="submit"].button {
|
||||
min-height: var(--lumi-control-height);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-button-bg);
|
||||
color: var(--lumi-button-text);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
font: 700 0.925rem/1 var(--lumi-font-body);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform var(--lumi-transition),
|
||||
background var(--lumi-transition),
|
||||
border-color var(--lumi-transition),
|
||||
box-shadow var(--lumi-transition);
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled):not(.disabled) {
|
||||
background: var(--lumi-button-hover);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled):not(.disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.button.subtle,
|
||||
.button.secondary {
|
||||
border-color: var(--lumi-border);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button.subtle:hover:not(:disabled):not(.disabled),
|
||||
.button.secondary:hover:not(:disabled):not(.disabled) {
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border));
|
||||
background: var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: var(--lumi-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button.danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--lumi-danger) 84%, black);
|
||||
}
|
||||
|
||||
.button:disabled,
|
||||
.button.disabled,
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: var(--lumi-control-height);
|
||||
height: var(--lumi-control-height);
|
||||
min-width: var(--lumi-control-height);
|
||||
border-color: var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text);
|
||||
transition: background var(--lumi-transition), transform var(--lumi-transition);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: var(--lumi-surface-raised);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--lumi-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--lumi-link) 35%, transparent);
|
||||
text-underline-offset: 0.22em;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.form-grid .field {
|
||||
min-width: 0;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.field > label:first-child,
|
||||
fieldset > legend {
|
||||
color: var(--lumi-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
|
||||
select,
|
||||
textarea,
|
||||
.table-search {
|
||||
width: 100%;
|
||||
min-height: var(--lumi-control-height);
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--lumi-input-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-input-bg);
|
||||
color: var(--lumi-input-text);
|
||||
font: inherit;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.035);
|
||||
transition: border-color var(--lumi-transition), box-shadow var(--lumi-transition);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-input-border));
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--lumi-focus);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-focus) 22%, transparent);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--lumi-primary);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
min-width: 3.5rem;
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.hint,
|
||||
.command-subtitle,
|
||||
.table-note,
|
||||
.table-page-label {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
min-width: 42rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text-muted);
|
||||
font: 700 0.75rem/1.3 var(--lumi-font-display);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
transition: background var(--lumi-transition);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.pill,
|
||||
.status-indicator,
|
||||
.level-pill,
|
||||
.origin-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.65rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
}
|
||||
|
||||
.status-indicator::before {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-right: var(--lumi-space-2);
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: var(--lumi-success);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--lumi-warning);
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
color: var(--lumi-danger);
|
||||
}
|
||||
|
||||
.flash,
|
||||
.alert,
|
||||
.callout {
|
||||
padding: var(--lumi-space-3) var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.flash.success,
|
||||
.alert.success,
|
||||
.callout.success {
|
||||
border-color: color-mix(in srgb, var(--lumi-success) 45%, var(--lumi-border));
|
||||
border-left-color: var(--lumi-success);
|
||||
background: color-mix(in srgb, var(--lumi-success) 10%, var(--lumi-surface));
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.flash.error,
|
||||
.alert.danger,
|
||||
.callout.danger {
|
||||
border-color: color-mix(in srgb, var(--lumi-danger) 45%, var(--lumi-border));
|
||||
border-left-color: var(--lumi-danger);
|
||||
background: color-mix(in srgb, var(--lumi-danger) 10%, var(--lumi-surface));
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.flash.info,
|
||||
.alert.info {
|
||||
border-left-color: var(--lumi-info);
|
||||
background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.list li {
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
padding: var(--lumi-space-4);
|
||||
background: rgba(5, 10, 12, 0.62);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(42rem, 100%);
|
||||
max-height: min(48rem, calc(100vh - 2rem));
|
||||
overflow-y: auto;
|
||||
padding: var(--lumi-space-5);
|
||||
border-radius: var(--lumi-radius-lg);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header h2,
|
||||
.modal-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tabs,
|
||||
.ai-tabs {
|
||||
display: flex;
|
||||
gap: var(--lumi-space-1);
|
||||
padding: var(--lumi-space-1);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs a,
|
||||
.ai-tabs a {
|
||||
min-height: 2.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
color: var(--lumi-text-muted);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs a:hover,
|
||||
.tabs a[aria-current="page"],
|
||||
.ai-tabs a:hover {
|
||||
background: var(--lumi-surface);
|
||||
color: var(--lumi-text);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
details {
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 10rem;
|
||||
padding: var(--lumi-space-6);
|
||||
border: 1px dashed var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state::before {
|
||||
content: "";
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border: 3px solid var(--lumi-border);
|
||||
border-top-color: var(--lumi-primary);
|
||||
border-radius: 50%;
|
||||
animation: lumi-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after,
|
||||
[data-tooltip]:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
z-index: 100;
|
||||
width: max-content;
|
||||
max-width: 18rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-text);
|
||||
color: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
font-size: 0.8rem;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@keyframes lumi-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid .field.full,
|
||||
.form-grid h2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel,
|
||||
.lumi-panel,
|
||||
.modal {
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.table-tools,
|
||||
.table-controls,
|
||||
.log-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-tools > *,
|
||||
.table-controls > *,
|
||||
.log-controls > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list li {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
260
src/web/public/lumi-layout.css
Normal file
260
src/web/public/lumi-layout.css
Normal file
@ -0,0 +1,260 @@
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--bg-2);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--lumi-font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
|
||||
radial-gradient(circle at 100% 100%, var(--bg-3) 0, transparent 38rem),
|
||||
var(--bg-2);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 17rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 17rem;
|
||||
gap: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-4);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
|
||||
border-color: var(--lumi-border);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
backdrop-filter: blur(18px);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
min-height: 3rem;
|
||||
padding: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
gap: var(--lumi-space-2);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--lumi-border) transparent;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-section[open] {
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.nav-section summary {
|
||||
min-height: 2.5rem;
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.nav-section summary:hover {
|
||||
background: var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: var(--lumi-space-1);
|
||||
padding: var(--lumi-space-2) 0 0 1.75rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
min-height: 2.5rem;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
border-color: var(--lumi-border);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: color-mix(in srgb, var(--lumi-primary) 14%, var(--lumi-surface));
|
||||
border-color: color-mix(in srgb, var(--lumi-primary) 28%, var(--lumi-border));
|
||||
color: var(--lumi-text);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: var(--lumi-content-max);
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 2.5vw, 2.5rem);
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.content > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: 100%;
|
||||
max-width: var(--lumi-content-max);
|
||||
margin: auto auto 0;
|
||||
padding: var(--lumi-space-5) clamp(1rem, 2.5vw, 2.5rem);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.lumi-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.lumi-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.lumi-cluster,
|
||||
.button-group,
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.lumi-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.page-header h1,
|
||||
.section-header h1,
|
||||
.section-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
background: rgba(5, 10, 12, 0.52);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.standalone-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.standalone-card {
|
||||
width: min(100%, 45rem);
|
||||
}
|
||||
|
||||
.standalone-detail {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
body.sidebar-collapsed .app-shell {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: 5.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) and (min-width: 901px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
width: min(19rem, 88vw);
|
||||
padding-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
body.sidebar-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-open .sidebar-scrim {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-topbar {
|
||||
min-height: 4rem;
|
||||
padding: var(--lumi-space-3) var(--lumi-space-4);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.content {
|
||||
gap: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: var(--lumi-space-5) var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.commands-header,
|
||||
.stats-header,
|
||||
.lumi-split {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header > *,
|
||||
.commands-header > *,
|
||||
.stats-header > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group,
|
||||
.page-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.button-group .button,
|
||||
.page-actions .button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
71
src/web/public/lumi-tokens.css
Normal file
71
src/web/public/lumi-tokens.css
Normal file
@ -0,0 +1,71 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--lumi-font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
|
||||
--lumi-text: var(--ink, #182026);
|
||||
--lumi-text-muted: var(--ink-soft, #5a6872);
|
||||
--lumi-primary: var(--sea, #176b75);
|
||||
--lumi-accent: var(--sun, #e58b2b);
|
||||
--lumi-danger: var(--rose, #bd4d4d);
|
||||
--lumi-success: #23845b;
|
||||
--lumi-warning: #a96612;
|
||||
--lumi-info: #3479a8;
|
||||
--lumi-link: var(--lumi-primary);
|
||||
--lumi-surface: var(--card, #ffffff);
|
||||
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
|
||||
--lumi-surface-raised: var(--surface-3, #edf2f3);
|
||||
--lumi-border: var(--border, #d8e0e3);
|
||||
--lumi-input-bg: var(--lumi-surface);
|
||||
--lumi-input-border: var(--lumi-border);
|
||||
--lumi-input-text: var(--lumi-text);
|
||||
--lumi-button-bg: var(--lumi-primary);
|
||||
--lumi-button-text: #ffffff;
|
||||
--lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black);
|
||||
--lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white);
|
||||
|
||||
--lumi-space-scale: 1;
|
||||
--lumi-space-1: calc(0.25rem * var(--lumi-space-scale));
|
||||
--lumi-space-2: calc(0.5rem * var(--lumi-space-scale));
|
||||
--lumi-space-3: calc(0.75rem * var(--lumi-space-scale));
|
||||
--lumi-space-4: calc(1rem * var(--lumi-space-scale));
|
||||
--lumi-space-5: calc(1.5rem * var(--lumi-space-scale));
|
||||
--lumi-space-6: calc(2rem * var(--lumi-space-scale));
|
||||
--lumi-space-7: calc(3rem * var(--lumi-space-scale));
|
||||
|
||||
--lumi-radius-sm: calc(var(--lumi-radius, 14px) * 0.58);
|
||||
--lumi-radius-md: var(--lumi-radius, 14px);
|
||||
--lumi-radius-lg: calc(var(--lumi-radius, 14px) * 1.42);
|
||||
--lumi-radius-pill: 999px;
|
||||
--lumi-shadow-sm: 0 1px 2px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 0.7));
|
||||
--lumi-shadow-md: 0 12px 34px rgba(11, 20, 24, var(--lumi-shadow-strength, 0.14));
|
||||
--lumi-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15));
|
||||
--lumi-transition: 150ms ease;
|
||||
--lumi-control-height: 2.75rem;
|
||||
--lumi-content-max: 1600px;
|
||||
|
||||
/* Compatibility aliases for existing core and plugin styles. */
|
||||
--text: var(--lumi-text);
|
||||
--muted: var(--lumi-text-muted);
|
||||
--primary: var(--lumi-primary);
|
||||
--accent: var(--lumi-accent);
|
||||
--danger: var(--lumi-danger);
|
||||
--success: var(--lumi-success);
|
||||
--warning: var(--lumi-warning);
|
||||
--info: var(--lumi-info);
|
||||
--panel: var(--lumi-surface);
|
||||
--panel-2: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
381
src/web/public/theme-editor.css
Normal file
381
src/web/public/theme-editor.css
Normal file
@ -0,0 +1,381 @@
|
||||
.theme-library {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-card.is-active {
|
||||
border-color: var(--lumi-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
height: 7rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--swatch-primary) 25%, transparent);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--swatch-bg);
|
||||
}
|
||||
|
||||
.theme-swatch span {
|
||||
border-radius: calc(var(--lumi-radius-sm) * 0.7);
|
||||
background: var(--swatch-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.theme-swatch span:first-child {
|
||||
grid-row: 1 / -1;
|
||||
background: var(--swatch-primary);
|
||||
}
|
||||
|
||||
.theme-swatch span:last-child {
|
||||
width: 58%;
|
||||
background: var(--swatch-accent);
|
||||
}
|
||||
|
||||
.theme-card-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-card-copy p {
|
||||
margin: var(--lumi-space-2) 0 0;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-card-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card-title h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.theme-kind {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.45rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-card-actions,
|
||||
.theme-card-more-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card-actions form {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.theme-card-more {
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
padding-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-card-more summary {
|
||||
cursor: pointer;
|
||||
color: var(--lumi-link);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-card-more-body {
|
||||
align-items: flex-end;
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
flex: 1 1 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.compact-form label {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-editor-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 25rem);
|
||||
align-items: start;
|
||||
gap: var(--lumi-space-5);
|
||||
scroll-margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-editor-main {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.theme-edit-form {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.theme-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.theme-fieldset legend {
|
||||
padding: 0 var(--lumi-space-2);
|
||||
font: 700 1.05rem/1 var(--lumi-font-display);
|
||||
}
|
||||
|
||||
.theme-control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-color-control,
|
||||
.theme-range-control {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-color-input {
|
||||
min-height: var(--lumi-control-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-color-input input {
|
||||
width: 3.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.theme-color-input output {
|
||||
color: var(--lumi-text-muted);
|
||||
font: 600 0.8rem/1 var(--lumi-font-mono);
|
||||
}
|
||||
|
||||
.theme-range-control {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-range-control > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-range-control output {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.advanced-theme-controls {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.advanced-theme-controls summary {
|
||||
cursor: pointer;
|
||||
color: var(--lumi-link);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advanced-theme-controls[open] summary {
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-editor-actions {
|
||||
position: sticky;
|
||||
bottom: var(--lumi-space-3);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 92%, transparent);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: sticky;
|
||||
top: var(--lumi-space-4);
|
||||
display: grid;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 26rem;
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, var(--bg-1), transparent 55%),
|
||||
var(--bg-2);
|
||||
}
|
||||
|
||||
.theme-preview-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border-right: 1px solid var(--lumi-border);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-preview-nav > span:not(.theme-preview-logo) {
|
||||
width: 1.8rem;
|
||||
height: 0.38rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-border);
|
||||
}
|
||||
|
||||
.theme-preview-logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-primary);
|
||||
color: var(--lumi-button-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.theme-preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-preview-heading {
|
||||
width: 60%;
|
||||
height: 1.2rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-text);
|
||||
}
|
||||
|
||||
.theme-preview-lines {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-lines span {
|
||||
width: 82%;
|
||||
height: 0.5rem;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-text-muted);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.theme-preview-lines span:last-child {
|
||||
width: 58%;
|
||||
}
|
||||
|
||||
.theme-preview-sample-card {
|
||||
margin-top: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card p {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-preview-sample-card input {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
margin-top: var(--lumi-space-3);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
border-color: var(--lumi-primary) !important;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.theme-editor-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: static;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.theme-editor-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-editor-actions .button-group,
|
||||
.theme-editor-actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
89
src/web/public/theme-editor.js
Normal file
89
src/web/public/theme-editor.js
Normal file
@ -0,0 +1,89 @@
|
||||
(() => {
|
||||
const editor = document.querySelector("[data-theme-editor]");
|
||||
const form = editor?.querySelector("[data-theme-form]");
|
||||
if (!editor || !form) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const originalScheme = root.dataset.colorScheme || "";
|
||||
const tokenVariables = {
|
||||
bg1: "--bg-1",
|
||||
bg2: "--bg-2",
|
||||
bg3: "--bg-3",
|
||||
text: "--ink",
|
||||
muted: "--ink-soft",
|
||||
accent: "--sea",
|
||||
accentAlt: "--sun",
|
||||
success: "--lumi-success",
|
||||
warning: "--lumi-warning",
|
||||
danger: "--rose",
|
||||
info: "--lumi-info",
|
||||
surface: "--card",
|
||||
surface2: "--surface-2",
|
||||
surface3: "--surface-3",
|
||||
border: "--border",
|
||||
link: "--lumi-link",
|
||||
buttonBg: "--lumi-button-bg",
|
||||
buttonText: "--lumi-button-text",
|
||||
buttonHover: "--lumi-button-hover",
|
||||
inputBg: "--lumi-input-bg",
|
||||
inputBorder: "--lumi-input-border",
|
||||
inputText: "--lumi-input-text",
|
||||
focusRing: "--lumi-focus"
|
||||
};
|
||||
const metricVariables = {
|
||||
radius: ["--lumi-radius", "px"],
|
||||
shadowStrength: ["--lumi-shadow-strength", ""],
|
||||
spacingScale: ["--lumi-space-scale", ""]
|
||||
};
|
||||
let previewMode = "light";
|
||||
|
||||
const updateOutputs = () => {
|
||||
form.querySelectorAll('input[type="color"]').forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("output");
|
||||
if (output) output.value = input.value.toUpperCase();
|
||||
});
|
||||
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreview = () => {
|
||||
root.dataset.colorScheme = previewMode;
|
||||
form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => {
|
||||
const variable = tokenVariables[input.dataset.themeToken];
|
||||
if (variable) root.style.setProperty(variable, input.value);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-role]").forEach((input) => {
|
||||
root.style.setProperty(`--role-${input.dataset.themeRole}`, input.value);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-metric]").forEach((input) => {
|
||||
const config = metricVariables[input.dataset.themeMetric];
|
||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
||||
});
|
||||
updateOutputs();
|
||||
};
|
||||
|
||||
form.addEventListener("input", applyPreview);
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
previewMode = button.dataset.themePreviewMode;
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((item) => {
|
||||
item.classList.toggle("is-selected", item === button);
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
||||
form.reset();
|
||||
applyPreview();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (originalScheme) root.dataset.colorScheme = originalScheme;
|
||||
else delete root.dataset.colorScheme;
|
||||
});
|
||||
|
||||
applyPreview();
|
||||
})();
|
||||
@ -14,6 +14,17 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
|
||||
|
||||
const { db } = require("../services/db");
|
||||
const { getSetting, setSetting, getAllSettings } = require("../services/settings");
|
||||
const {
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
getActiveTheme,
|
||||
getThemeById,
|
||||
listThemes,
|
||||
renameCustomTheme,
|
||||
saveCustomTheme,
|
||||
setActiveTheme,
|
||||
valuesFromRequest
|
||||
} = require("../services/themes");
|
||||
const { getRoleFlags, hasAccess } = require("../services/rbac");
|
||||
const {
|
||||
buildDiscordAuthUrl,
|
||||
@ -1373,6 +1384,12 @@ function buildCustomPageSrcdoc(page, theme) {
|
||||
` --bg-1: ${theme.light.bg1};`,
|
||||
` --bg-2: ${theme.light.bg2};`,
|
||||
` --bg-3: ${theme.light.bg3};`,
|
||||
` --success: ${theme.light.success};`,
|
||||
` --warning: ${theme.light.warning};`,
|
||||
` --info: ${theme.light.info};`,
|
||||
` --link: ${theme.light.link};`,
|
||||
` --radius: ${theme.metrics.radius}px;`,
|
||||
` --spacing-scale: ${theme.metrics.spacingScale};`,
|
||||
"}",
|
||||
"@media (prefers-color-scheme: dark) {",
|
||||
" :root {",
|
||||
@ -1388,6 +1405,10 @@ function buildCustomPageSrcdoc(page, theme) {
|
||||
` --bg-1: ${theme.dark.bg1};`,
|
||||
` --bg-2: ${theme.dark.bg2};`,
|
||||
` --bg-3: ${theme.dark.bg3};`,
|
||||
` --success: ${theme.dark.success};`,
|
||||
` --warning: ${theme.dark.warning};`,
|
||||
` --info: ${theme.dark.info};`,
|
||||
` --link: ${theme.dark.link};`,
|
||||
" }",
|
||||
"}"
|
||||
].join("\n")
|
||||
@ -1457,41 +1478,7 @@ function setFlash(req, type, message) {
|
||||
}
|
||||
|
||||
function getThemeSettings() {
|
||||
return {
|
||||
light: {
|
||||
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
|
||||
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
|
||||
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
|
||||
text: getSetting("theme_light_text", "#121518"),
|
||||
muted: getSetting("theme_light_text_muted", "#2c3137"),
|
||||
accent: getSetting("theme_light_accent", "#0f6a78"),
|
||||
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
|
||||
danger: getSetting("theme_light_danger", "#d66d5c"),
|
||||
surface: getSetting("theme_light_surface", "#ffffff"),
|
||||
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
|
||||
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
|
||||
border: getSetting("theme_light_border", "#e3ddd6")
|
||||
},
|
||||
dark: {
|
||||
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
|
||||
bg2: getSetting("theme_dark_bg_2", "#16181b"),
|
||||
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
|
||||
text: getSetting("theme_dark_text", "#f2f0ec"),
|
||||
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
|
||||
accent: getSetting("theme_dark_accent", "#4fb6c2"),
|
||||
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
|
||||
danger: getSetting("theme_dark_danger", "#e08173"),
|
||||
surface: getSetting("theme_dark_surface", "#232629"),
|
||||
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
|
||||
surface3: getSetting("theme_dark_surface_3", "#30353a"),
|
||||
border: getSetting("theme_dark_border", "#34393d")
|
||||
},
|
||||
role: {
|
||||
public: getSetting("theme_role_public", "#ffffff"),
|
||||
mod: getSetting("theme_role_mod", "#2cb678"),
|
||||
admin: getSetting("theme_role_admin", "#e35678")
|
||||
}
|
||||
};
|
||||
return getActiveTheme();
|
||||
}
|
||||
|
||||
function getDiscordSettings() {
|
||||
@ -3969,48 +3956,137 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
});
|
||||
|
||||
app.get("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
const activeTheme = getActiveTheme();
|
||||
const requestedEdit = String(req.query.edit || "");
|
||||
const editingTheme = requestedEdit
|
||||
? getThemeById(requestedEdit)
|
||||
: activeTheme.builtin
|
||||
? null
|
||||
: activeTheme;
|
||||
res.render("admin-theme", {
|
||||
title: "Theming",
|
||||
theme: getThemeSettings()
|
||||
theme: activeTheme,
|
||||
activeTheme,
|
||||
themes: listThemes(),
|
||||
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
const fields = [
|
||||
"theme_light_bg_1",
|
||||
"theme_light_bg_2",
|
||||
"theme_light_bg_3",
|
||||
"theme_light_text",
|
||||
"theme_light_text_muted",
|
||||
"theme_light_accent",
|
||||
"theme_light_accent_alt",
|
||||
"theme_light_danger",
|
||||
"theme_light_surface",
|
||||
"theme_light_surface_2",
|
||||
"theme_light_surface_3",
|
||||
"theme_light_border",
|
||||
"theme_dark_bg_1",
|
||||
"theme_dark_bg_2",
|
||||
"theme_dark_bg_3",
|
||||
"theme_dark_text",
|
||||
"theme_dark_text_muted",
|
||||
"theme_dark_accent",
|
||||
"theme_dark_accent_alt",
|
||||
"theme_dark_danger",
|
||||
"theme_dark_surface",
|
||||
"theme_dark_surface_2",
|
||||
"theme_dark_surface_3",
|
||||
"theme_dark_border",
|
||||
"theme_role_public",
|
||||
"theme_role_mod",
|
||||
"theme_role_admin"
|
||||
];
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
setSetting(field, req.body[field].trim());
|
||||
}
|
||||
app.post("/admin/theming/select", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const theme = setActiveTheme(String(req.body.theme_id || ""));
|
||||
setFlash(req, "success", `${theme.name} is now active.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const theme = duplicateTheme(req.body.theme_id, req.body.name);
|
||||
setFlash(req, "success", `${theme.name} created. You can edit it below.`);
|
||||
return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
return res.redirect("/admin/theming");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => {
|
||||
const themeId = `custom:${req.params.id}`;
|
||||
try {
|
||||
const current = getThemeById(themeId);
|
||||
if (!current) throw new Error("Theme not found.");
|
||||
const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current));
|
||||
if (req.body.apply === "on") setActiveTheme(theme.id);
|
||||
setFlash(
|
||||
req,
|
||||
"success",
|
||||
req.body.apply === "on"
|
||||
? `${theme.name} saved and applied.`
|
||||
: `${theme.name} saved.`
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error.validationErrors?.join(" ") || error.message;
|
||||
setFlash(req, "error", detail);
|
||||
}
|
||||
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`);
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => {
|
||||
const themeId = `custom:${req.params.id}`;
|
||||
try {
|
||||
const theme = renameCustomTheme(themeId, req.body.name);
|
||||
setFlash(req, "success", `Theme renamed to ${theme.name}.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`);
|
||||
});
|
||||
|
||||
app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
deleteCustomTheme(`custom:${req.params.id}`);
|
||||
setFlash(req, "success", "Custom theme deleted.");
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
app.post("/admin/theming", requireRole("admin"), (req, res) => {
|
||||
let createdTarget = null;
|
||||
try {
|
||||
let target = getActiveTheme();
|
||||
if (target.builtin) {
|
||||
target = duplicateTheme(target.id, "Legacy Custom");
|
||||
createdTarget = target.id;
|
||||
}
|
||||
const values = JSON.parse(JSON.stringify(target));
|
||||
const legacyUpdates = [];
|
||||
const legacyMap = {
|
||||
theme_light_bg_1: ["light", "bg1"],
|
||||
theme_light_bg_2: ["light", "bg2"],
|
||||
theme_light_bg_3: ["light", "bg3"],
|
||||
theme_light_text: ["light", "text"],
|
||||
theme_light_text_muted: ["light", "muted"],
|
||||
theme_light_accent: ["light", "accent"],
|
||||
theme_light_accent_alt: ["light", "accentAlt"],
|
||||
theme_light_danger: ["light", "danger"],
|
||||
theme_light_surface: ["light", "surface"],
|
||||
theme_light_surface_2: ["light", "surface2"],
|
||||
theme_light_surface_3: ["light", "surface3"],
|
||||
theme_light_border: ["light", "border"],
|
||||
theme_dark_bg_1: ["dark", "bg1"],
|
||||
theme_dark_bg_2: ["dark", "bg2"],
|
||||
theme_dark_bg_3: ["dark", "bg3"],
|
||||
theme_dark_text: ["dark", "text"],
|
||||
theme_dark_text_muted: ["dark", "muted"],
|
||||
theme_dark_accent: ["dark", "accent"],
|
||||
theme_dark_accent_alt: ["dark", "accentAlt"],
|
||||
theme_dark_danger: ["dark", "danger"],
|
||||
theme_dark_surface: ["dark", "surface"],
|
||||
theme_dark_surface_2: ["dark", "surface2"],
|
||||
theme_dark_surface_3: ["dark", "surface3"],
|
||||
theme_dark_border: ["dark", "border"],
|
||||
theme_role_public: ["role", "public"],
|
||||
theme_role_mod: ["role", "mod"],
|
||||
theme_role_admin: ["role", "admin"]
|
||||
};
|
||||
for (const [field, [group, key]] of Object.entries(legacyMap)) {
|
||||
if (req.body[field] === undefined) continue;
|
||||
values[group][key] = String(req.body[field]).trim();
|
||||
legacyUpdates.push([field, values[group][key]]);
|
||||
}
|
||||
saveCustomTheme(target.id, values);
|
||||
for (const [field, value] of legacyUpdates) setSetting(field, value);
|
||||
setActiveTheme(target.id);
|
||||
setFlash(req, "success", "Theme updated.");
|
||||
} catch (error) {
|
||||
if (createdTarget) deleteCustomTheme(createdTarget);
|
||||
setFlash(req, "error", error.message);
|
||||
}
|
||||
setFlash(req, "success", "Theme updated.");
|
||||
res.redirect("/admin/theming");
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Admin dashboard</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "Admin dashboard",
|
||||
description: "Configure Lumi, manage community tools, and maintain the installation."
|
||||
}) %>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Settings</h2>
|
||||
@ -9,8 +13,8 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Theming</h2>
|
||||
<p>Adjust light and dark mode colors.</p>
|
||||
<a href="/admin/theming" class="link">Edit theme</a>
|
||||
<p>Select protected presets or create editable custom themes.</p>
|
||||
<a href="/admin/theming" class="link">Open theme studio</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Commands</h2>
|
||||
@ -41,15 +45,17 @@
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Maintenance</h2>
|
||||
<form method="post" action="/admin/check-update" class="inline-form">
|
||||
<button type="submit" class="button subtle">Check for updates</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/update" class="inline-form">
|
||||
<button type="submit" class="button">Update from git</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/restart" class="inline-form">
|
||||
<button type="submit" class="button subtle">Restart bot</button>
|
||||
</form>
|
||||
<div class="button-group">
|
||||
<form method="post" action="/admin/check-update" class="inline-form">
|
||||
<button type="submit" class="button subtle">Check for updates</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/update" class="inline-form">
|
||||
<button type="submit" class="button">Update from git</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/restart" class="inline-form">
|
||||
<button type="submit" class="button subtle">Restart bot</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Custom pages</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Content",
|
||||
pageTitle: "Custom pages",
|
||||
description: "Publish role-aware HTML or Markdown pages without changing routes."
|
||||
}) %>
|
||||
<form method="post" action="/admin/pages" class="form-grid" data-page-form>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
@ -49,8 +53,9 @@
|
||||
</form>
|
||||
<h2>Existing pages</h2>
|
||||
<% if (!pages.length) { %>
|
||||
<p>No pages created yet.</p>
|
||||
<div class="empty-state">No pages created yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -144,6 +149,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Plugins</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Extensions",
|
||||
pageTitle: "Plugins",
|
||||
description: "Install, update, enable, and remove Lumi modules."
|
||||
}) %>
|
||||
<h2>Installed plugins</h2>
|
||||
<% if (!plugins.length) { %>
|
||||
<p>No plugins installed.</p>
|
||||
<div class="empty-state">No plugins installed.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -36,6 +41,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<section class="card">
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Settings</h1>
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "Settings",
|
||||
description: "Manage core behavior, updates, and platform integrations."
|
||||
}) %>
|
||||
<form method="post" action="/admin/settings" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Site title</label>
|
||||
|
||||
@ -1,122 +1,266 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Theming</h1>
|
||||
<p>Update light and dark mode colors used across the WebUI.</p>
|
||||
<form method="post" action="/admin/theming" class="form-grid theme-grid">
|
||||
<h2>Light mode</h2>
|
||||
<div class="field">
|
||||
<label>Background 1</label>
|
||||
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 2</label>
|
||||
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 3</label>
|
||||
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Text</label>
|
||||
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Muted text</label>
|
||||
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent</label>
|
||||
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent alt</label>
|
||||
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Danger</label>
|
||||
<input type="color" name="theme_light_danger" value="<%= theme.light.danger %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface</label>
|
||||
<input type="color" name="theme_light_surface" value="<%= theme.light.surface %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 2</label>
|
||||
<input type="color" name="theme_light_surface_2" value="<%= theme.light.surface2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 3</label>
|
||||
<input type="color" name="theme_light_surface_3" value="<%= theme.light.surface3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Border</label>
|
||||
<input type="color" name="theme_light_border" value="<%= theme.light.border %>" />
|
||||
</div>
|
||||
<link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
|
||||
<%
|
||||
const fieldLabels = {
|
||||
bg1: "Background glow",
|
||||
bg2: "Page background",
|
||||
bg3: "Accent glow",
|
||||
text: "Primary text",
|
||||
muted: "Muted text",
|
||||
accent: "Primary",
|
||||
accentAlt: "Accent",
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
danger: "Danger",
|
||||
info: "Information",
|
||||
surface: "Surface",
|
||||
surface2: "Subtle surface",
|
||||
surface3: "Raised surface",
|
||||
border: "Border",
|
||||
link: "Link",
|
||||
buttonBg: "Button background",
|
||||
buttonText: "Button text",
|
||||
buttonHover: "Button hover",
|
||||
inputBg: "Input background",
|
||||
inputBorder: "Input border",
|
||||
inputText: "Input text",
|
||||
focusRing: "Focus ring"
|
||||
};
|
||||
const basicFields = [
|
||||
"accent", "accentAlt", "bg2", "surface", "surface2", "text", "muted",
|
||||
"border", "success", "warning", "danger", "info"
|
||||
];
|
||||
const advancedFields = [
|
||||
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
|
||||
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
|
||||
];
|
||||
%>
|
||||
|
||||
<h2>Dark mode</h2>
|
||||
<div class="field">
|
||||
<label>Background 1</label>
|
||||
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 2</label>
|
||||
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Background 3</label>
|
||||
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Text</label>
|
||||
<input type="color" name="theme_dark_text" value="<%= theme.dark.text %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Muted text</label>
|
||||
<input type="color" name="theme_dark_text_muted" value="<%= theme.dark.muted %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent</label>
|
||||
<input type="color" name="theme_dark_accent" value="<%= theme.dark.accent %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Accent alt</label>
|
||||
<input type="color" name="theme_dark_accent_alt" value="<%= theme.dark.accentAlt %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Danger</label>
|
||||
<input type="color" name="theme_dark_danger" value="<%= theme.dark.danger %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface</label>
|
||||
<input type="color" name="theme_dark_surface" value="<%= theme.dark.surface %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 2</label>
|
||||
<input type="color" name="theme_dark_surface_2" value="<%= theme.dark.surface2 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Surface 3</label>
|
||||
<input type="color" name="theme_dark_surface_3" value="<%= theme.dark.surface3 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Border</label>
|
||||
<input type="color" name="theme_dark_border" value="<%= theme.dark.border %>" />
|
||||
</div>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<span class="eyebrow">Appearance</span>
|
||||
<h1>Theme studio</h1>
|
||||
<p class="command-subtitle">
|
||||
Select a protected Lumi preset or duplicate one into a custom theme.
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-indicator status-success">
|
||||
Active: <strong><%= activeTheme.name %></strong>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<h2>Role colors</h2>
|
||||
<div class="field">
|
||||
<label>Public</label>
|
||||
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" />
|
||||
<section class="theme-library" aria-labelledby="theme-library-title">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 id="theme-library-title">Theme library</h2>
|
||||
<p class="hint">Built-in themes are read-only and always remain available.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Moderator</label>
|
||||
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Admin</label>
|
||||
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" />
|
||||
</div>
|
||||
<button type="submit" class="button">Save theme</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="theme-card-grid">
|
||||
<% themes.forEach((item) => { %>
|
||||
<% const customId = item.builtin ? null : item.id.replace("custom:", ""); %>
|
||||
<article class="theme-card <%= item.id === activeTheme.id ? 'is-active' : '' %>">
|
||||
<div class="theme-swatch" style="--swatch-bg: <%= item.light.bg2 %>; --swatch-surface: <%= item.light.surface %>; --swatch-primary: <%= item.light.accent %>; --swatch-accent: <%= item.light.accentAlt %>;">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="theme-card-copy">
|
||||
<div class="theme-card-title">
|
||||
<h3><%= item.name %></h3>
|
||||
<span class="theme-kind"><%- item.builtin ? "Built-in · read-only" : "Custom" %></span>
|
||||
</div>
|
||||
<p><%= item.description %></p>
|
||||
</div>
|
||||
<div class="theme-card-actions">
|
||||
<% if (item.id !== activeTheme.id) { %>
|
||||
<form method="post" action="/admin/theming/select">
|
||||
<input type="hidden" name="theme_id" value="<%= item.id %>" />
|
||||
<button type="submit" class="button subtle">Apply</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<span class="button subtle disabled" aria-current="true">Active</span>
|
||||
<% } %>
|
||||
<% if (!item.builtin) { %>
|
||||
<a class="button subtle" href="/admin/theming?edit=<%= encodeURIComponent(item.id) %>#theme-editor">Edit</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<details class="theme-card-more">
|
||||
<summary>More actions</summary>
|
||||
<div class="theme-card-more-body">
|
||||
<form method="post" action="/admin/theming/duplicate" class="compact-form">
|
||||
<input type="hidden" name="theme_id" value="<%= item.id %>" />
|
||||
<label>
|
||||
<span>Copy name</span>
|
||||
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
|
||||
</label>
|
||||
<button type="submit" class="button">Duplicate</button>
|
||||
</form>
|
||||
<% if (!item.builtin) { %>
|
||||
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
|
||||
<label>
|
||||
<span>Theme name</span>
|
||||
<input name="name" value="<%= item.name %>" maxlength="60" required />
|
||||
</label>
|
||||
<button type="submit" class="button subtle">Rename</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/theming/custom/<%= customId %>/delete"
|
||||
data-confirm-title="Delete custom theme"
|
||||
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
|
||||
>
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if (editingTheme) { %>
|
||||
<section class="theme-editor-shell" id="theme-editor" data-theme-editor>
|
||||
<div class="theme-editor-main card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<span class="eyebrow">Custom theme</span>
|
||||
<h2>Edit <%= editingTheme.name %></h2>
|
||||
<p class="hint">Based on <%= themes.find((item) => item.id === editingTheme.baseThemeId)?.name || "Lumi Default" %>.</p>
|
||||
</div>
|
||||
<div class="button-group" aria-label="Preview color scheme">
|
||||
<button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button>
|
||||
<button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/theming/custom/<%= editingTheme.id.replace('custom:', '') %>/save"
|
||||
class="theme-edit-form"
|
||||
data-theme-form
|
||||
>
|
||||
<% ["light", "dark"].forEach((mode) => { %>
|
||||
<fieldset class="theme-fieldset" data-theme-mode-fields="<%= mode %>">
|
||||
<legend><%= mode === "light" ? "Light mode" : "Dark mode" %></legend>
|
||||
<div class="theme-control-grid">
|
||||
<% basicFields.forEach((field) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= fieldLabels[field] %></span>
|
||||
<span class="theme-color-input">
|
||||
<input
|
||||
type="color"
|
||||
name="<%= mode %>_<%= field %>"
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
<details class="advanced-theme-controls">
|
||||
<summary>Advanced <%= mode %> variables</summary>
|
||||
<div class="theme-control-grid">
|
||||
<% advancedFields.forEach((field) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= fieldLabels[field] %></span>
|
||||
<span class="theme-color-input">
|
||||
<input
|
||||
type="color"
|
||||
name="<%= mode %>_<%= field %>"
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
<% }) %>
|
||||
|
||||
<fieldset class="theme-fieldset">
|
||||
<legend>Roles and shape</legend>
|
||||
<div class="theme-control-grid">
|
||||
<% ["public", "mod", "admin"].forEach((role) => { %>
|
||||
<label class="theme-color-control">
|
||||
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
|
||||
<span class="theme-color-input">
|
||||
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" />
|
||||
<output><%= editingTheme.role[role] %></output>
|
||||
</span>
|
||||
</label>
|
||||
<% }) %>
|
||||
<label class="theme-range-control">
|
||||
<span>Border radius <output data-range-output><%= editingTheme.metrics.radius %>px</output></span>
|
||||
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Shadow strength <output data-range-output><%= editingTheme.metrics.shadowStrength %></output></span>
|
||||
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Spacing scale <output data-range-output><%= editingTheme.metrics.spacingScale %></output></span>
|
||||
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="theme-editor-actions">
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text">Apply globally after saving</span>
|
||||
</label>
|
||||
<div class="button-group">
|
||||
<button type="button" class="button subtle" data-theme-reset>Revert unsaved</button>
|
||||
<a href="/admin/theming" class="button subtle">Cancel</a>
|
||||
<button type="submit" class="button">Save theme</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="theme-preview card" aria-label="Live theme preview">
|
||||
<span class="eyebrow">Live preview</span>
|
||||
<div class="theme-preview-window">
|
||||
<div class="theme-preview-nav">
|
||||
<span class="theme-preview-logo">L</span>
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="theme-preview-content">
|
||||
<div class="theme-preview-heading"></div>
|
||||
<div class="theme-preview-lines"><span></span><span></span></div>
|
||||
<div class="theme-preview-sample-card">
|
||||
<strong>Community overview</strong>
|
||||
<p>Preview text, surfaces, borders, and controls before saving.</p>
|
||||
<div class="button-group">
|
||||
<span class="button">Primary</span>
|
||||
<span class="button subtle">Secondary</span>
|
||||
</div>
|
||||
<input value="Input preview" readonly aria-label="Input preview" />
|
||||
<div class="theme-preview-statuses">
|
||||
<span class="status-success">Success</span>
|
||||
<span class="status-warning">Warning</span>
|
||||
<span class="status-danger">Danger</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Preview changes are local to this page until you save.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<% } else { %>
|
||||
<section class="empty-state">
|
||||
<div>
|
||||
<h2>Create a custom theme to edit</h2>
|
||||
<p>Open "More actions" on any built-in or custom theme, then duplicate it.</p>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<script src="/theme-editor.js?v=<%= assetVersion %>"></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Updates</h1>
|
||||
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p>
|
||||
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Maintenance",
|
||||
pageTitle: "Updates",
|
||||
description: "Apply git or ZIP updates with automatic pre-update snapshots."
|
||||
}) %>
|
||||
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@ -51,11 +54,12 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Snapshots</h2>
|
||||
<% if (!snapshots.length) { %>
|
||||
<p>No snapshots yet.</p>
|
||||
<% } else { %>
|
||||
<table class="table">
|
||||
<h2>Snapshots</h2>
|
||||
<% if (!snapshots.length) { %>
|
||||
<div class="empty-state">No snapshots yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Snapshot</th>
|
||||
@ -70,7 +74,8 @@
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</section>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Users</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Community",
|
||||
pageTitle: "Users",
|
||||
description: "Review linked identities, notes, and internal usernames."
|
||||
}) %>
|
||||
<% if (!users.length) { %>
|
||||
<p>No users yet.</p>
|
||||
<div class="empty-state">No users yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-tools">
|
||||
<input
|
||||
@ -12,6 +16,7 @@
|
||||
data-table-filter="user-list"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table" data-table="user-list">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -71,6 +76,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<div class="modal-backdrop" data-notes-modal aria-hidden="true">
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Leaderboards</h1>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Community",
|
||||
pageTitle: "Leaderboards",
|
||||
description: "Browse activity across core features and installed plugins."
|
||||
}) %>
|
||||
</section>
|
||||
|
||||
<% if (!sections || !sections.length) { %>
|
||||
<section class="card">
|
||||
<section class="empty-state">
|
||||
<p>No activity recorded yet.</p>
|
||||
</section>
|
||||
<% } else { %>
|
||||
@ -20,6 +24,7 @@
|
||||
<% if (!board.rows || !board.rows.length) { %>
|
||||
<p><%= board.emptyMessage || "No data recorded yet." %></p>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -46,6 +51,7 @@
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
|
||||
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
|
||||
<div class="modal-header">
|
||||
|
||||
@ -4,46 +4,13 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= title %> - <%= siteTitle %></title>
|
||||
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
|
||||
<% if (theme) { %>
|
||||
<style>
|
||||
:root {
|
||||
--ink: <%= theme.light.text %>;
|
||||
--ink-soft: <%= theme.light.muted %>;
|
||||
--sea: <%= theme.light.accent %>;
|
||||
--sun: <%= theme.light.accentAlt %>;
|
||||
--rose: <%= theme.light.danger %>;
|
||||
--card: <%= theme.light.surface %>;
|
||||
--surface-2: <%= theme.light.surface2 %>;
|
||||
--surface-3: <%= theme.light.surface3 %>;
|
||||
--border: <%= theme.light.border %>;
|
||||
--bg-1: <%= theme.light.bg1 %>;
|
||||
--bg-2: <%= theme.light.bg2 %>;
|
||||
--bg-3: <%= theme.light.bg3 %>;
|
||||
--role-public: <%= theme.role.public %>;
|
||||
--role-mod: <%= theme.role.mod %>;
|
||||
--role-admin: <%= theme.role.admin %>;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<% } %>
|
||||
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
||||
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
||||
<%- include("theme-vars", { theme }) %>
|
||||
</head>
|
||||
<body>
|
||||
<body data-theme-id="<%= theme ? theme.id : '' %>">
|
||||
<% const icons = {
|
||||
home: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>',
|
||||
spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l2.4 5.6L20 11l-5.6 2.4L12 19l-2.4-5.6L4 11l5.6-2.4z" fill="currentColor"/></svg>',
|
||||
|
||||
11
src/web/views/partials/page-header.ejs
Normal file
11
src/web/views/partials/page-header.ejs
Normal file
@ -0,0 +1,11 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<% if (typeof eyebrow !== "undefined" && eyebrow) { %>
|
||||
<span class="eyebrow"><%= eyebrow %></span>
|
||||
<% } %>
|
||||
<h1><%= pageTitle %></h1>
|
||||
<% if (typeof description !== "undefined" && description) { %>
|
||||
<p class="command-subtitle"><%= description %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
91
src/web/views/partials/theme-vars.ejs
Normal file
91
src/web/views/partials/theme-vars.ejs
Normal file
@ -0,0 +1,91 @@
|
||||
<% if (theme) { %>
|
||||
<style>
|
||||
:root {
|
||||
--ink: <%= theme.light.text %>;
|
||||
--ink-soft: <%= theme.light.muted %>;
|
||||
--sea: <%= theme.light.accent %>;
|
||||
--sun: <%= theme.light.accentAlt %>;
|
||||
--rose: <%= theme.light.danger %>;
|
||||
--card: <%= theme.light.surface %>;
|
||||
--surface-2: <%= theme.light.surface2 %>;
|
||||
--surface-3: <%= theme.light.surface3 %>;
|
||||
--border: <%= theme.light.border %>;
|
||||
--bg-1: <%= theme.light.bg1 %>;
|
||||
--bg-2: <%= theme.light.bg2 %>;
|
||||
--bg-3: <%= theme.light.bg3 %>;
|
||||
--lumi-success: <%= theme.light.success %>;
|
||||
--lumi-warning: <%= theme.light.warning %>;
|
||||
--lumi-info: <%= theme.light.info %>;
|
||||
--lumi-link: <%= theme.light.link %>;
|
||||
--lumi-button-bg: <%= theme.light.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.light.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.light.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.light.inputBg %>;
|
||||
--lumi-input-border: <%= theme.light.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.light.inputText %>;
|
||||
--lumi-focus: <%= theme.light.focusRing %>;
|
||||
--lumi-radius: <%= theme.metrics.radius %>px;
|
||||
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
|
||||
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
|
||||
--role-public: <%= theme.role.public %>;
|
||||
--role-mod: <%= theme.role.mod %>;
|
||||
--role-admin: <%= theme.role.admin %>;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-color-scheme="light"]) {
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
--lumi-success: <%= theme.dark.success %>;
|
||||
--lumi-warning: <%= theme.dark.warning %>;
|
||||
--lumi-info: <%= theme.dark.info %>;
|
||||
--lumi-link: <%= theme.dark.link %>;
|
||||
--lumi-button-bg: <%= theme.dark.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.dark.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.dark.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.dark.inputBg %>;
|
||||
--lumi-input-border: <%= theme.dark.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.dark.inputText %>;
|
||||
--lumi-focus: <%= theme.dark.focusRing %>;
|
||||
}
|
||||
}
|
||||
:root[data-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ink: <%= theme.dark.text %>;
|
||||
--ink-soft: <%= theme.dark.muted %>;
|
||||
--sea: <%= theme.dark.accent %>;
|
||||
--sun: <%= theme.dark.accentAlt %>;
|
||||
--rose: <%= theme.dark.danger %>;
|
||||
--card: <%= theme.dark.surface %>;
|
||||
--surface-2: <%= theme.dark.surface2 %>;
|
||||
--surface-3: <%= theme.dark.surface3 %>;
|
||||
--border: <%= theme.dark.border %>;
|
||||
--bg-1: <%= theme.dark.bg1 %>;
|
||||
--bg-2: <%= theme.dark.bg2 %>;
|
||||
--bg-3: <%= theme.dark.bg3 %>;
|
||||
--lumi-success: <%= theme.dark.success %>;
|
||||
--lumi-warning: <%= theme.dark.warning %>;
|
||||
--lumi-info: <%= theme.dark.info %>;
|
||||
--lumi-link: <%= theme.dark.link %>;
|
||||
--lumi-button-bg: <%= theme.dark.buttonBg %>;
|
||||
--lumi-button-text: <%= theme.dark.buttonText %>;
|
||||
--lumi-button-hover: <%= theme.dark.buttonHover %>;
|
||||
--lumi-input-bg: <%= theme.dark.inputBg %>;
|
||||
--lumi-input-border: <%= theme.dark.inputBorder %>;
|
||||
--lumi-input-text: <%= theme.dark.inputText %>;
|
||||
--lumi-focus: <%= theme.dark.focusRing %>;
|
||||
}
|
||||
:root[data-color-scheme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
</style>
|
||||
<% } %>
|
||||
@ -1,7 +1,10 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<h1>Initial setup</h1>
|
||||
<p>Enable the platforms you plan to use, then run each wizard to configure credentials.</p>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Get started",
|
||||
pageTitle: "Initial setup",
|
||||
description: "Enable the platforms you plan to use, then configure each connection."
|
||||
}) %>
|
||||
<p class="hint">Once at least one platform is configured, you can log in and manage everything from the WebUI.</p>
|
||||
<div class="grid">
|
||||
<% (platforms || []).forEach((platform) => { %>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user