ui: refine theme editor and add localhost login
This commit is contained in:
parent
7ab2b61110
commit
0512fa5931
@ -11,7 +11,11 @@ from visual tokens and reusable components.
|
||||
- `src/web/public/lumi-layout.css`: application shell, sidebar, content
|
||||
containers, responsive grids, stacks, clusters, and mobile navigation.
|
||||
- `src/web/public/lumi-components.css`: buttons, forms, cards, tables, lists,
|
||||
badges, alerts, tabs, modals, empty/loading/error states, and tooltips.
|
||||
badges, alerts, tabs, modals, empty/loading/error states, stateful action
|
||||
buttons, and tooltips.
|
||||
- `src/web/public/lumi-state-button.js` and
|
||||
`src/web/views/partials/state-button.ejs`: reusable multi-state button
|
||||
behavior for submit/loading/success actions.
|
||||
- `src/web/public/styles.css`: legacy and feature-specific styles that still use
|
||||
the shared tokens. New general-purpose styling belongs in the Lumi UI files.
|
||||
- `src/web/views/partials/page-header.ejs`: standard page title and description.
|
||||
@ -38,8 +42,15 @@ theme falls back to Lumi Default.
|
||||
|
||||
The compact editor exposes common colors first. Advanced controls cover
|
||||
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
|
||||
shadow strength, and spacing scale. The server accepts only six-digit hex colors,
|
||||
bounded metric values, and readable text/button/input contrast.
|
||||
shadow strength, and spacing scale. Typography controls use constrained font
|
||||
presets plus bounded base-size, heading-scale, and control-density ranges. The
|
||||
server accepts only six-digit hex colors, supported font presets, bounded metric
|
||||
values, and readable text/button/input contrast.
|
||||
|
||||
The live preview updates colors, role colors, metrics, and typography before
|
||||
save. The editor also shows contrast warnings for the current preview mode,
|
||||
offers a reset-to-base action for inherited custom themes, and provides an
|
||||
optional desktop pop-out preview window that stays synchronized with the editor.
|
||||
|
||||
Missing or invalid stored values are replaced from the custom theme's built-in
|
||||
base. Existing installations with modified legacy `theme_light_*`,
|
||||
@ -48,7 +59,20 @@ base. Existing installations with modified legacy `theme_light_*`,
|
||||
route remains supported and writes into an editable custom theme.
|
||||
|
||||
Run `npm run verify:webui` to compile every EJS view and exercise built-in theme
|
||||
validation plus custom duplicate, apply, edit validation, rename, and delete.
|
||||
validation plus custom duplicate, apply, edit validation, typography validation,
|
||||
stateful theme actions, localhost login rendering, rename, and delete.
|
||||
|
||||
## Localhost Login
|
||||
|
||||
Development builds opened from `localhost`, `127.0.0.1`, or `::1` show a
|
||||
**Localhost Login** option. It defaults to username `admin` and password `admin`
|
||||
unless those settings have already been changed. The option is not inserted into
|
||||
the login list, cannot be used, and does not satisfy setup requirements for
|
||||
non-localhost requests.
|
||||
|
||||
Admins can change the localhost username and password from **Admin > Settings**
|
||||
when the settings page itself is accessed through localhost. Leaving the
|
||||
password field blank keeps the existing password.
|
||||
|
||||
## Visual references
|
||||
|
||||
|
||||
@ -55,12 +55,16 @@ function verifyThemeService() {
|
||||
|
||||
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
|
||||
assert.strictEqual(copy.builtin, false);
|
||||
assert.strictEqual(copy.typography.bodyFont, "lumi");
|
||||
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/);
|
||||
invalid.light.text = copy.light.text;
|
||||
invalid.typography.bodyFont = "external-css";
|
||||
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /font preset/);
|
||||
|
||||
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
|
||||
assert.strictEqual(renamed.name, "Verified Theme");
|
||||
@ -80,6 +84,8 @@ function verifyThemeService() {
|
||||
activeTheme: renamed,
|
||||
themes: themes.listThemes(),
|
||||
editingTheme: renamed,
|
||||
editingBaseTheme: themes.getThemeById(renamed.baseThemeId),
|
||||
fontStacks: themes.FONT_STACKS,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: { username: "Admin" },
|
||||
@ -90,8 +96,29 @@ function verifyThemeService() {
|
||||
softError: null
|
||||
}, { filename: themeView });
|
||||
assert(rendered.includes("data-theme-editor"));
|
||||
assert(rendered.includes("data-theme-font"));
|
||||
assert(rendered.includes("data-theme-popout"));
|
||||
assert(rendered.includes("data-lumi-state-button"));
|
||||
assert(rendered.includes("Built-in · read-only"));
|
||||
|
||||
const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs");
|
||||
const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), {
|
||||
title: "Localhost Login",
|
||||
username: "admin",
|
||||
siteTitle: "Lumi Bot",
|
||||
assetVersion: "verify",
|
||||
theme: renamed,
|
||||
botAvatar: null,
|
||||
navSections: [],
|
||||
user: null,
|
||||
userAvatar: null,
|
||||
userInitial: "",
|
||||
platformLogins: [{ id: "localhost", label: "Localhost Login", configured: true, loginPath: "/auth/localhost" }],
|
||||
flash: null,
|
||||
softError: null
|
||||
}, { filename: loginView });
|
||||
assert(loginRendered.includes("admin / admin"));
|
||||
|
||||
const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
|
||||
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
|
||||
title: "Access restricted",
|
||||
|
||||
@ -86,6 +86,8 @@ function ensureDefaults() {
|
||||
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
|
||||
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
|
||||
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
|
||||
localhost_login_username: "admin",
|
||||
localhost_login_password: "admin",
|
||||
theme_light_bg_1: "#ffe5c4",
|
||||
theme_light_bg_2: "#f4efe8",
|
||||
theme_light_bg_3: "#e9f3f1",
|
||||
|
||||
@ -5,6 +5,30 @@ 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 FONT_STACKS = Object.freeze({
|
||||
lumi: {
|
||||
label: "Lumi Sans",
|
||||
stack: '"Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
},
|
||||
system: {
|
||||
label: "System UI",
|
||||
stack: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
},
|
||||
rounded: {
|
||||
label: "Rounded",
|
||||
stack: 'ui-rounded, "Nunito Sans", "Aptos", Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
},
|
||||
editorial: {
|
||||
label: "Editorial",
|
||||
stack: 'Georgia, "Times New Roman", ui-serif, serif'
|
||||
},
|
||||
mono: {
|
||||
label: "Mono",
|
||||
stack: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace'
|
||||
}
|
||||
});
|
||||
|
||||
const TYPOGRAPHY_FIELDS = ["bodyFont", "displayFont", "monoFont", "baseSize", "headingScale", "controlScale"];
|
||||
|
||||
const MODE_COLOR_FIELDS = [
|
||||
"bg1",
|
||||
@ -98,6 +122,17 @@ const DEFAULT_THEME_VALUES = {
|
||||
radius: 14,
|
||||
shadowStrength: 0.14,
|
||||
spacingScale: 1
|
||||
},
|
||||
typography: {
|
||||
bodyFont: "lumi",
|
||||
displayFont: "system",
|
||||
monoFont: "mono",
|
||||
baseSize: 16,
|
||||
headingScale: 1,
|
||||
controlScale: 1,
|
||||
bodyFontStack: FONT_STACKS.lumi.stack,
|
||||
displayFontStack: FONT_STACKS.system.stack,
|
||||
monoFontStack: FONT_STACKS.mono.stack
|
||||
}
|
||||
};
|
||||
|
||||
@ -118,7 +153,8 @@ function createBuiltin(id, name, description, overrides = {}) {
|
||||
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 || {}) }
|
||||
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) },
|
||||
typography: normalizeTypography(overrides.typography, DEFAULT_THEME_VALUES.typography)
|
||||
});
|
||||
}
|
||||
|
||||
@ -294,7 +330,8 @@ function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
|
||||
light: mergeMode(baseTheme.light, source.light),
|
||||
dark: mergeMode(baseTheme.dark, source.dark),
|
||||
role: { ...baseTheme.role, ...(source.role || {}) },
|
||||
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) }
|
||||
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) },
|
||||
typography: normalizeTypography(source.typography, baseTheme.typography)
|
||||
};
|
||||
|
||||
for (const mode of ["light", "dark"]) {
|
||||
@ -344,6 +381,27 @@ function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeFontKey(value, fallback) {
|
||||
return Object.prototype.hasOwnProperty.call(FONT_STACKS, value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeTypography(values = {}, fallback = DEFAULT_THEME_VALUES.typography) {
|
||||
const bodyFont = normalizeFontKey(values.bodyFont, fallback.bodyFont);
|
||||
const displayFont = normalizeFontKey(values.displayFont, fallback.displayFont);
|
||||
const monoFont = normalizeFontKey(values.monoFont, fallback.monoFont);
|
||||
return {
|
||||
bodyFont,
|
||||
displayFont,
|
||||
monoFont,
|
||||
baseSize: clampNumber(values.baseSize, 14, 19, fallback.baseSize),
|
||||
headingScale: clampNumber(values.headingScale, 0.9, 1.2, fallback.headingScale),
|
||||
controlScale: clampNumber(values.controlScale, 0.9, 1.12, fallback.controlScale),
|
||||
bodyFontStack: FONT_STACKS[bodyFont].stack,
|
||||
displayFontStack: FONT_STACKS[displayFont].stack,
|
||||
monoFontStack: FONT_STACKS[monoFont].stack
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
@ -398,6 +456,24 @@ function validateThemeValues(values) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ["bodyFont", "displayFont", "monoFont"]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(FONT_STACKS, values?.typography?.[field])) {
|
||||
errors.push(`typography.${field} must be a supported font preset.`);
|
||||
}
|
||||
}
|
||||
|
||||
const typographyRules = [
|
||||
["baseSize", 14, 19],
|
||||
["headingScale", 0.9, 1.2],
|
||||
["controlScale", 0.9, 1.12]
|
||||
];
|
||||
for (const [field, min, max] of typographyRules) {
|
||||
const value = Number(values?.typography?.[field]);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
errors.push(`typography.${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) {
|
||||
@ -611,7 +687,7 @@ function deleteCustomTheme(themeId) {
|
||||
}
|
||||
|
||||
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
||||
const values = { light: {}, dark: {}, role: {}, metrics: {} };
|
||||
const values = { light: {}, dark: {}, role: {}, metrics: {}, typography: {} };
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const field of MODE_COLOR_FIELDS) {
|
||||
values[mode][field] = String(
|
||||
@ -631,14 +707,34 @@ function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
||||
values.metrics.spacingScale = Number(
|
||||
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
|
||||
);
|
||||
values.typography.bodyFont = String(
|
||||
body?.typography_bodyFont ?? fallbackTheme.typography.bodyFont
|
||||
);
|
||||
values.typography.displayFont = String(
|
||||
body?.typography_displayFont ?? fallbackTheme.typography.displayFont
|
||||
);
|
||||
values.typography.monoFont = String(
|
||||
body?.typography_monoFont ?? fallbackTheme.typography.monoFont
|
||||
);
|
||||
values.typography.baseSize = Number(
|
||||
body?.typography_baseSize ?? fallbackTheme.typography.baseSize
|
||||
);
|
||||
values.typography.headingScale = Number(
|
||||
body?.typography_headingScale ?? fallbackTheme.typography.headingScale
|
||||
);
|
||||
values.typography.controlScale = Number(
|
||||
body?.typography_controlScale ?? fallbackTheme.typography.controlScale
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_THEME_ID,
|
||||
FONT_STACKS,
|
||||
MODE_COLOR_FIELDS,
|
||||
ROLE_COLOR_FIELDS,
|
||||
TYPOGRAPHY_FIELDS,
|
||||
contrastRatio,
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
|
||||
@ -22,12 +22,12 @@ h6 {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.55rem);
|
||||
font-size: calc(clamp(1.75rem, 4vw, 2.55rem) * var(--lumi-heading-scale));
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
|
||||
font-size: calc(clamp(1.25rem, 2.5vw, 1.6rem) * var(--lumi-heading-scale));
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@ -185,6 +185,42 @@ button:disabled {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.lumi-state-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lumi-state-btn[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.lumi-state-btn-content {
|
||||
display: grid;
|
||||
grid-template-areas: "stack";
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.lumi-state-btn [data-state-view] {
|
||||
grid-area: stack;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.lumi-state-btn [data-state-view][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.lumi-state-btn-spinner {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 0.14em solid color-mix(in srgb, currentColor 28%, transparent);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: lumi-state-spin 750ms linear infinite;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: var(--lumi-control-height);
|
||||
height: var(--lumi-control-height);
|
||||
@ -508,7 +544,21 @@ details > summary {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lumi-state-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
h1 {
|
||||
font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale));
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(clamp(1.15rem, 6vw, 1.45rem) * var(--lumi-heading-scale));
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -522,11 +572,34 @@ details > summary {
|
||||
.panel,
|
||||
.lumi-panel,
|
||||
.modal {
|
||||
padding: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--lumi-space-5);
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
width: 9rem;
|
||||
height: 9rem;
|
||||
right: -5rem;
|
||||
top: -5rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button.button,
|
||||
input[type="submit"].button,
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
|
||||
select,
|
||||
textarea,
|
||||
.table-search {
|
||||
min-height: min(var(--lumi-control-height), 2.5rem);
|
||||
}
|
||||
|
||||
.button,
|
||||
button.button,
|
||||
input[type="submit"].button {
|
||||
padding: 0.55rem 0.8rem;
|
||||
}
|
||||
|
||||
.table-tools,
|
||||
|
||||
@ -6,7 +6,7 @@ html {
|
||||
|
||||
body {
|
||||
font-family: var(--lumi-font-body);
|
||||
font-size: 1rem;
|
||||
font-size: var(--lumi-font-size-base);
|
||||
line-height: 1.55;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
|
||||
@ -199,6 +199,12 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body.sidebar-collapsed .app-shell,
|
||||
.app-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar,
|
||||
.sidebar {
|
||||
width: min(19rem, 88vw);
|
||||
padding-top: var(--lumi-space-3);
|
||||
@ -225,9 +231,18 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: calc(var(--lumi-font-size-base) * 0.94);
|
||||
}
|
||||
|
||||
.mobile-topbar {
|
||||
min-height: 3.5rem;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-3);
|
||||
gap: var(--lumi-space-3);
|
||||
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
|
||||
64
src/web/public/lumi-state-button.js
Normal file
64
src/web/public/lumi-state-button.js
Normal file
@ -0,0 +1,64 @@
|
||||
(() => {
|
||||
const buttons = new Set();
|
||||
|
||||
const getViews = (button) => Array.from(button.querySelectorAll("[data-state-view]"));
|
||||
|
||||
const setState = (button, state, options = {}) => {
|
||||
if (!button) return;
|
||||
const nextState = state || button.dataset.defaultState || "idle";
|
||||
const busyState = button.dataset.loadingState || "loading";
|
||||
const isBusy = nextState === busyState || options.busy === true;
|
||||
|
||||
button.dataset.state = nextState;
|
||||
button.setAttribute("aria-busy", isBusy ? "true" : "false");
|
||||
|
||||
getViews(button).forEach((view) => {
|
||||
const isVisible = view.dataset.stateView === nextState;
|
||||
view.hidden = !isVisible;
|
||||
view.setAttribute("aria-hidden", isVisible ? "false" : "true");
|
||||
});
|
||||
|
||||
if (button.dataset.disableWhileBusy === "true") {
|
||||
button.disabled = isBusy;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (button) => {
|
||||
setState(button, button?.dataset.defaultState || "idle");
|
||||
};
|
||||
|
||||
const scheduleReset = (button) => {
|
||||
const delay = Number(button?.dataset.resetDelay || 0);
|
||||
if (delay > 0) window.setTimeout(() => reset(button), delay);
|
||||
};
|
||||
|
||||
const initButton = (button) => {
|
||||
if (buttons.has(button)) return;
|
||||
buttons.add(button);
|
||||
button.classList.add("lumi-state-btn");
|
||||
setState(button, button.dataset.state || button.dataset.defaultState || "idle");
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-lumi-state-button]").forEach(initButton);
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const submitter = event.submitter;
|
||||
if (!submitter?.matches?.("[data-lumi-state-button]")) return;
|
||||
initButton(submitter);
|
||||
setState(submitter, submitter.dataset.loadingState || "loading", { busy: true });
|
||||
}, true);
|
||||
|
||||
window.LumiStateButton = {
|
||||
init: initButton,
|
||||
setState,
|
||||
reset,
|
||||
success(button) {
|
||||
setState(button, button?.dataset.successState || "success");
|
||||
scheduleReset(button);
|
||||
},
|
||||
error(button) {
|
||||
setState(button, button?.dataset.errorState || "error");
|
||||
scheduleReset(button);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -4,6 +4,9 @@
|
||||
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-font-size-base: 16px;
|
||||
--lumi-heading-scale: 1;
|
||||
--lumi-control-scale: 1;
|
||||
|
||||
--lumi-text: var(--ink, #182026);
|
||||
--lumi-text-muted: var(--ink-soft, #5a6872);
|
||||
@ -43,7 +46,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-control-height: calc(2.75rem * var(--lumi-control-scale));
|
||||
--lumi-content-max: 1600px;
|
||||
|
||||
/* Compatibility aliases for existing core and plugin styles. */
|
||||
|
||||
@ -163,6 +163,7 @@
|
||||
}
|
||||
|
||||
.theme-color-control,
|
||||
.theme-select-control,
|
||||
.theme-range-control {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
@ -209,6 +210,38 @@
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.theme-select-control {
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.theme-inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.theme-validation-panel {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-validation-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-warning {
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-warning) 40%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: color-mix(in srgb, var(--lumi-warning) 11%, var(--lumi-surface));
|
||||
color: var(--lumi-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-theme-controls {
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
@ -247,6 +280,23 @@
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-preview-header .eyebrow {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.theme-popout-button {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 26rem;
|
||||
display: grid;
|
||||
@ -365,6 +415,45 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.theme-library,
|
||||
.theme-editor-main,
|
||||
.theme-edit-form,
|
||||
.theme-editor-shell {
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-card-grid,
|
||||
.theme-control-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.theme-card,
|
||||
.theme-fieldset,
|
||||
.theme-range-control,
|
||||
.theme-select-control {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.theme-preview-window {
|
||||
min-height: 16rem;
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.theme-preview-nav,
|
||||
.theme-preview-content,
|
||||
.theme-preview-sample-card {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.theme-popout-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -35,6 +35,15 @@
|
||||
shadowStrength: ["--lumi-shadow-strength", ""],
|
||||
spacingScale: ["--lumi-space-scale", ""]
|
||||
};
|
||||
const typographyVariables = {
|
||||
body: "--lumi-font-body",
|
||||
display: "--lumi-font-display",
|
||||
mono: "--lumi-font-mono",
|
||||
baseSize: ["--lumi-font-size-base", "px"],
|
||||
headingScale: ["--lumi-heading-scale", ""],
|
||||
controlScale: ["--lumi-control-scale", ""]
|
||||
};
|
||||
let popout = null;
|
||||
let previewMode = "light";
|
||||
|
||||
const updateOutputs = () => {
|
||||
@ -46,6 +55,10 @@
|
||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
||||
});
|
||||
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
|
||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreview = () => {
|
||||
@ -61,10 +74,89 @@
|
||||
const config = metricVariables[input.dataset.themeMetric];
|
||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-font]").forEach((select) => {
|
||||
const variable = typographyVariables[select.dataset.themeFont];
|
||||
const stack = select.selectedOptions[0]?.dataset.fontStack;
|
||||
if (variable && stack) root.style.setProperty(variable, stack);
|
||||
});
|
||||
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
|
||||
const config = typographyVariables[input.dataset.themeTypography];
|
||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
||||
});
|
||||
updateOutputs();
|
||||
updateWarnings();
|
||||
syncPopout();
|
||||
};
|
||||
|
||||
const parseHex = (value) => {
|
||||
const hex = String(value || "").replace("#", "");
|
||||
return [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
|
||||
};
|
||||
|
||||
const luminance = (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];
|
||||
};
|
||||
|
||||
const contrastRatio = (left, right) => {
|
||||
const a = luminance(left);
|
||||
const b = luminance(right);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
};
|
||||
|
||||
const getModeValue = (token) =>
|
||||
form.querySelector(`[data-theme-mode="${previewMode}"][data-theme-token="${token}"]`)?.value;
|
||||
|
||||
const updateWarnings = () => {
|
||||
const panel = editor.querySelector("[data-theme-warnings]");
|
||||
if (!panel) return;
|
||||
const checks = [
|
||||
["Text on surface", getModeValue("text"), getModeValue("surface")],
|
||||
["Button text", getModeValue("buttonText"), getModeValue("buttonBg")],
|
||||
["Input text", getModeValue("inputText"), getModeValue("inputBg")]
|
||||
];
|
||||
const warnings = checks
|
||||
.map(([label, foreground, background]) => [label, contrastRatio(foreground, background)])
|
||||
.filter(([, ratio]) => Number.isFinite(ratio) && ratio < 4.5);
|
||||
panel.replaceChildren(
|
||||
...warnings.map(([label, ratio]) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "theme-warning";
|
||||
item.textContent = `${label} contrast is ${ratio.toFixed(2)}:1. Save will reject values below 4.5:1.`;
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getPreviewMarkup = () => editor.querySelector(".theme-preview-window")?.outerHTML || "";
|
||||
|
||||
const currentPreviewVariables = () => {
|
||||
const variables = [];
|
||||
Object.values(tokenVariables).forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
["--role-public", "--role-mod", "--role-admin"].forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
Object.values(metricVariables).forEach(([name]) => variables.push([name, root.style.getPropertyValue(name)]));
|
||||
Object.values(typographyVariables).forEach((config) => {
|
||||
const name = Array.isArray(config) ? config[0] : config;
|
||||
variables.push([name, root.style.getPropertyValue(name)]);
|
||||
});
|
||||
return variables.filter(([, value]) => value);
|
||||
};
|
||||
|
||||
const syncPopout = () => {
|
||||
if (!popout || popout.closed) return;
|
||||
popout.document.documentElement.dataset.colorScheme = previewMode;
|
||||
currentPreviewVariables().forEach(([name, value]) => {
|
||||
popout.document.documentElement.style.setProperty(name, value);
|
||||
});
|
||||
};
|
||||
|
||||
form.addEventListener("input", applyPreview);
|
||||
form.addEventListener("change", applyPreview);
|
||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
previewMode = button.dataset.themePreviewMode;
|
||||
@ -78,6 +170,40 @@
|
||||
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
||||
form.reset();
|
||||
applyPreview();
|
||||
window.LumiStateButton?.success(editor.querySelector("[data-theme-reset]"));
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-reset-to-base]")?.addEventListener("click", () => {
|
||||
form.querySelectorAll("[data-base-value]").forEach((input) => {
|
||||
input.value = input.dataset.baseValue;
|
||||
});
|
||||
applyPreview();
|
||||
});
|
||||
|
||||
editor.querySelector("[data-theme-popout]")?.addEventListener("click", () => {
|
||||
popout = window.open("", "lumi-theme-preview", "width=430,height=760,menubar=no,toolbar=no,location=no,status=no");
|
||||
const status = editor.querySelector("[data-theme-popout-status]");
|
||||
if (!popout) {
|
||||
if (status) status.textContent = "Pop-out preview was blocked by the browser.";
|
||||
return;
|
||||
}
|
||||
popout.document.open();
|
||||
popout.document.write(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Lumi Theme Preview</title>
|
||||
<link rel="stylesheet" href="/lumi-tokens.css">
|
||||
<link rel="stylesheet" href="/lumi-components.css">
|
||||
<link rel="stylesheet" href="/theme-editor.css">
|
||||
<style>body{margin:0;padding:1rem;background:var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base)}.theme-preview-window{width:100%;min-height:calc(100vh - 2rem)}</style>
|
||||
</head>
|
||||
<body>${getPreviewMarkup()}</body>
|
||||
</html>`);
|
||||
popout.document.close();
|
||||
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
|
||||
syncPopout();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
|
||||
@ -17,6 +17,7 @@ const { getSetting, setSetting, getAllSettings } = require("../services/settings
|
||||
const {
|
||||
deleteCustomTheme,
|
||||
duplicateTheme,
|
||||
FONT_STACKS,
|
||||
getActiveTheme,
|
||||
getThemeById,
|
||||
listThemes,
|
||||
@ -112,7 +113,39 @@ function isConfigured() {
|
||||
return platforms.some((platform) => platform.configured);
|
||||
}
|
||||
|
||||
function getPrimaryLoginPlatform() {
|
||||
function normalizeHostName(value) {
|
||||
const raw = String(value || "").toLowerCase();
|
||||
if (raw.startsWith("[")) {
|
||||
return raw.slice(1, raw.indexOf("]"));
|
||||
}
|
||||
if (raw === "::1" || raw === "::ffff:127.0.0.1") {
|
||||
return raw;
|
||||
}
|
||||
return raw.replace(/:\d+$/, "");
|
||||
}
|
||||
|
||||
function isLocalhostLoginAvailable(req) {
|
||||
if (!req) return false;
|
||||
const host = normalizeHostName(req.hostname || req.get("host"));
|
||||
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
|
||||
}
|
||||
|
||||
function getLocalhostLoginPlatform(req) {
|
||||
if (!isLocalhostLoginAvailable(req)) return null;
|
||||
return {
|
||||
id: "localhost",
|
||||
label: "Localhost Login",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
supported: true,
|
||||
supportsLogin: true,
|
||||
loginPath: "/auth/localhost"
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryLoginPlatform(req) {
|
||||
const localhostPlatform = getLocalhostLoginPlatform(req);
|
||||
if (localhostPlatform) return localhostPlatform;
|
||||
const platforms = getPlatformStatus().filter(
|
||||
(platform) =>
|
||||
platform.supported && platform.enabled && platform.supportsLogin
|
||||
@ -123,13 +156,13 @@ function getPrimaryLoginPlatform() {
|
||||
return platforms.find((platform) => platform.configured) || platforms[0];
|
||||
}
|
||||
|
||||
function getLoginRedirectPath() {
|
||||
const platform = getPrimaryLoginPlatform();
|
||||
function getLoginRedirectPath(req) {
|
||||
const platform = getPrimaryLoginPlatform(req);
|
||||
return platform?.loginPath || "/setup";
|
||||
}
|
||||
|
||||
function requireConfigured(req, res, next) {
|
||||
if (!isConfigured() && !req.path.startsWith("/setup")) {
|
||||
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
next();
|
||||
@ -137,7 +170,7 @@ function requireConfigured(req, res, next) {
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session.user) {
|
||||
return res.redirect(getLoginRedirectPath());
|
||||
return res.redirect(getLoginRedirectPath(req));
|
||||
}
|
||||
next();
|
||||
}
|
||||
@ -181,7 +214,7 @@ function formatDuration(totalMs) {
|
||||
function requireRole(role) {
|
||||
return (req, res, next) => {
|
||||
if (!req.session.user) {
|
||||
return res.redirect(getLoginRedirectPath());
|
||||
return res.redirect(getLoginRedirectPath(req));
|
||||
}
|
||||
if (!hasAccess(req.session.user, role)) {
|
||||
return res.status(403).render("error", {
|
||||
@ -1943,9 +1976,14 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
res.locals.botAvatar = getSetting("bot_avatar_url", null);
|
||||
const platformStatus = getPlatformStatus();
|
||||
res.locals.platforms = platformStatus;
|
||||
res.locals.platformLogins = platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
||||
);
|
||||
const localhostLogin = getLocalhostLoginPlatform(req);
|
||||
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
|
||||
res.locals.platformLogins = [
|
||||
...(localhostLogin ? [localhostLogin] : []),
|
||||
...platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
||||
)
|
||||
];
|
||||
res.locals.platformLinks = platformStatus.filter(
|
||||
(platform) => platform.supported && platform.enabled && platform.supportsLink
|
||||
);
|
||||
@ -2564,6 +2602,53 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/auth/localhost", (req, res) => {
|
||||
if (!isLocalhostLoginAvailable(req)) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Login unavailable",
|
||||
message: "Localhost Login is only available from a localhost request."
|
||||
});
|
||||
}
|
||||
res.render("localhost-login", {
|
||||
title: "Localhost Login",
|
||||
username: getSetting("localhost_login_username", "admin")
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/localhost", (req, res) => {
|
||||
if (!isLocalhostLoginAvailable(req)) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Login unavailable",
|
||||
message: "Localhost Login is only available from a localhost request."
|
||||
});
|
||||
}
|
||||
const username = String(req.body.username || "").trim();
|
||||
const password = String(req.body.password || "");
|
||||
const expectedUsername = String(getSetting("localhost_login_username", "admin"));
|
||||
const expectedPassword = String(getSetting("localhost_login_password", "admin"));
|
||||
if (username !== expectedUsername || password !== expectedPassword) {
|
||||
setFlash(req, "error", "Invalid localhost username or password.");
|
||||
return res.redirect("/auth/localhost");
|
||||
}
|
||||
const profile = ensureUserForIdentity({
|
||||
provider: "localhost",
|
||||
providerUserId: expectedUsername,
|
||||
displayName: expectedUsername,
|
||||
fallbackName: "Localhost Admin"
|
||||
});
|
||||
req.session.user = {
|
||||
id: profile.id,
|
||||
username: profile.internal_username,
|
||||
avatar: null,
|
||||
roles: ["localhost-admin"],
|
||||
isAdmin: true,
|
||||
isMod: true,
|
||||
isLocalhost: true
|
||||
};
|
||||
setFlash(req, "success", "Logged in with Localhost Login.");
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect("/");
|
||||
@ -3480,6 +3565,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
title: "Settings",
|
||||
settings: getAllSettings(),
|
||||
platforms: getPlatformStatus(),
|
||||
localhostLoginAvailable: isLocalhostLoginAvailable(req),
|
||||
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
|
||||
});
|
||||
});
|
||||
@ -3505,6 +3591,16 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLocalhostLoginAvailable(req)) {
|
||||
const localhostUsername = String(req.body.localhost_login_username || "").trim();
|
||||
if (localhostUsername) {
|
||||
setSetting("localhost_login_username", localhostUsername);
|
||||
}
|
||||
const localhostPassword = String(req.body.localhost_login_password || "");
|
||||
if (localhostPassword) {
|
||||
setSetting("localhost_login_password", localhostPassword);
|
||||
}
|
||||
}
|
||||
const platformStatus = getPlatformStatus();
|
||||
const nextPlatformValues = new Map();
|
||||
for (const platform of platformStatus) {
|
||||
@ -3516,7 +3612,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
platform.supportsLogin &&
|
||||
nextPlatformValues.get(platform.id)
|
||||
);
|
||||
if (!hasLoginPlatform) {
|
||||
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
|
||||
setFlash(
|
||||
req,
|
||||
"error",
|
||||
@ -3963,12 +4059,17 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
: activeTheme.builtin
|
||||
? null
|
||||
: activeTheme;
|
||||
const editingBaseTheme = editingTheme && !editingTheme.builtin
|
||||
? getThemeById(editingTheme.baseThemeId)
|
||||
: null;
|
||||
res.render("admin-theme", {
|
||||
title: "Theming",
|
||||
theme: activeTheme,
|
||||
activeTheme,
|
||||
themes: listThemes(),
|
||||
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null
|
||||
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null,
|
||||
editingBaseTheme,
|
||||
fontStacks: FONT_STACKS
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -62,9 +62,9 @@
|
||||
<p class="hint">Git update checks use the configured remote and branch.</p>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<h2>Platform Integration</h2>
|
||||
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||
<div class="field full">
|
||||
<h2>Platform Integration</h2>
|
||||
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||
<div class="platform-grid">
|
||||
<% (platforms || []).forEach((platform) => { %>
|
||||
<div class="platform-card">
|
||||
@ -95,11 +95,26 @@
|
||||
<% } %>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (localhostLoginAvailable) { %>
|
||||
<div class="field full">
|
||||
<h2>Localhost Login</h2>
|
||||
<p class="hint">Development-only login shown only on localhost. Defaults are admin / admin until changed.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Localhost username</label>
|
||||
<input name="localhost_login_username" value="<%= settings.localhost_login_username || 'admin' %>" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New localhost password</label>
|
||||
<input name="localhost_login_password" type="password" placeholder="Leave blank to keep current password" autocomplete="new-password" />
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Navigation icons</h2>
|
||||
|
||||
@ -34,6 +34,8 @@
|
||||
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
|
||||
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
|
||||
];
|
||||
const fontStackEntries = Object.entries((typeof fontStacks !== "undefined" && fontStacks) || {});
|
||||
const baseTheme = (typeof editingBaseTheme !== "undefined" && editingBaseTheme) || editingTheme || activeTheme;
|
||||
%>
|
||||
|
||||
<header class="page-header">
|
||||
@ -74,7 +76,15 @@
|
||||
<% 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>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Apply" },
|
||||
{ id: "loading", text: "Applying", spinner: true },
|
||||
{ id: "success", text: "Applied" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<span class="button subtle disabled" aria-current="true">Active</span>
|
||||
@ -92,7 +102,14 @@
|
||||
<span>Copy name</span>
|
||||
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
|
||||
</label>
|
||||
<button type="submit" class="button">Duplicate</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Duplicate" },
|
||||
{ id: "loading", text: "Duplicating", spinner: true },
|
||||
{ id: "success", text: "Created" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<% if (!item.builtin) { %>
|
||||
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
|
||||
@ -100,7 +117,15 @@
|
||||
<span>Theme name</span>
|
||||
<input name="name" value="<%= item.name %>" maxlength="60" required />
|
||||
</label>
|
||||
<button type="submit" class="button subtle">Rename</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
classes: "subtle",
|
||||
states: [
|
||||
{ id: "idle", text: "Rename" },
|
||||
{ id: "loading", text: "Renaming", spinner: true },
|
||||
{ id: "success", text: "Renamed" }
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
@ -153,6 +178,7 @@
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
data-base-value="<%= baseTheme[mode][field] %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
@ -172,6 +198,7 @@
|
||||
value="<%= editingTheme[mode][field] %>"
|
||||
data-theme-token="<%= field %>"
|
||||
data-theme-mode="<%= mode %>"
|
||||
data-base-value="<%= baseTheme[mode][field] %>"
|
||||
/>
|
||||
<output><%= editingTheme[mode][field] %></output>
|
||||
</span>
|
||||
@ -189,26 +216,67 @@
|
||||
<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 %>" />
|
||||
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" data-base-value="<%= baseTheme.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" />
|
||||
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" data-base-value="<%= baseTheme.metrics.radius %>" />
|
||||
</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" />
|
||||
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" data-base-value="<%= baseTheme.metrics.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" />
|
||||
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" data-base-value="<%= baseTheme.metrics.spacingScale %>" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="theme-fieldset">
|
||||
<legend>Typography</legend>
|
||||
<div class="theme-control-grid">
|
||||
<% [
|
||||
["bodyFont", "Body font", "data-theme-font='body'"],
|
||||
["displayFont", "Heading font", "data-theme-font='display'"],
|
||||
["monoFont", "Monospace font", "data-theme-font='mono'"]
|
||||
].forEach(([field, label, dataAttr]) => { %>
|
||||
<label class="theme-select-control">
|
||||
<span><%= label %></span>
|
||||
<select
|
||||
name="typography_<%= field %>"
|
||||
<%- dataAttr %>
|
||||
data-base-value="<%= baseTheme.typography[field] %>"
|
||||
>
|
||||
<% fontStackEntries.forEach(([key, font]) => { %>
|
||||
<option value="<%= key %>" data-font-stack="<%= font.stack %>" <%= editingTheme.typography[field] === key ? "selected" : "" %>><%= font.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<% }) %>
|
||||
<label class="theme-range-control">
|
||||
<span>Base text size <output data-range-output><%= editingTheme.typography.baseSize %>px</output></span>
|
||||
<input type="range" name="typography_baseSize" min="14" max="19" step="1" value="<%= editingTheme.typography.baseSize %>" data-theme-typography="baseSize" data-unit="px" data-base-value="<%= baseTheme.typography.baseSize %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Heading scale <output data-range-output><%= editingTheme.typography.headingScale %></output></span>
|
||||
<input type="range" name="typography_headingScale" min="0.9" max="1.2" step="0.01" value="<%= editingTheme.typography.headingScale %>" data-theme-typography="headingScale" data-base-value="<%= baseTheme.typography.headingScale %>" />
|
||||
</label>
|
||||
<label class="theme-range-control">
|
||||
<span>Control density <output data-range-output><%= editingTheme.typography.controlScale %></output></span>
|
||||
<input type="range" name="typography_controlScale" min="0.9" max="1.12" step="0.01" value="<%= editingTheme.typography.controlScale %>" data-theme-typography="controlScale" data-base-value="<%= baseTheme.typography.controlScale %>" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="theme-inline-actions">
|
||||
<button type="button" class="button subtle" data-theme-reset-to-base>Reset changed controls to base theme</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="theme-validation-panel" data-theme-warnings aria-live="polite"></div>
|
||||
|
||||
<div class="theme-editor-actions">
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
|
||||
@ -216,16 +284,35 @@
|
||||
<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>
|
||||
<%- include("partials/state-button", {
|
||||
type: "button",
|
||||
classes: "subtle",
|
||||
attrs: "data-theme-reset",
|
||||
resetDelay: 1200,
|
||||
states: [
|
||||
{ id: "idle", text: "Revert unsaved" },
|
||||
{ id: "success", text: "Reverted" }
|
||||
]
|
||||
}) %>
|
||||
<a href="/admin/theming" class="button subtle">Cancel</a>
|
||||
<button type="submit" class="button">Save theme</button>
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Save theme" },
|
||||
{ id: "loading", text: "Saving", spinner: true },
|
||||
{ id: "success", text: "Saved" }
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="theme-preview card" aria-label="Live theme preview">
|
||||
<span class="eyebrow">Live preview</span>
|
||||
<div class="theme-preview-header">
|
||||
<span class="eyebrow">Live preview</span>
|
||||
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
|
||||
</div>
|
||||
<div class="theme-preview-window">
|
||||
<div class="theme-preview-nav">
|
||||
<span class="theme-preview-logo">L</span>
|
||||
@ -250,7 +337,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Preview changes are local to this page until you save.</p>
|
||||
<p class="hint" data-theme-popout-status>Preview changes are local to this page until you save.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<% } else { %>
|
||||
|
||||
23
src/web/views/localhost-login.ejs
Normal file
23
src/web/views/localhost-login.ejs
Normal file
@ -0,0 +1,23 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<section class="standalone-card card">
|
||||
<span class="eyebrow">Development access</span>
|
||||
<h1>Localhost Login</h1>
|
||||
<p class="hint">
|
||||
This login option is only available when the site is opened from localhost.
|
||||
The default credentials are admin / admin until changed in settings.
|
||||
</p>
|
||||
<form method="post" action="/auth/localhost" class="form-grid">
|
||||
<div class="field full">
|
||||
<label>Username</label>
|
||||
<input name="username" value="<%= username || 'admin' %>" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Login locally</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
@ -25,6 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/app.js?v=<%= assetVersion %>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
36
src/web/views/partials/state-button.ejs
Normal file
36
src/web/views/partials/state-button.ejs
Normal file
@ -0,0 +1,36 @@
|
||||
<%
|
||||
const stateButtonStates = (typeof states !== "undefined" && states) || [
|
||||
{ id: "idle", text: (typeof text !== "undefined" && text) || "Submit" },
|
||||
{ id: "loading", text: (typeof loadingText !== "undefined" && loadingText) || "Working...", spinner: true },
|
||||
{ id: "success", text: (typeof successText !== "undefined" && successText) || "Done" }
|
||||
];
|
||||
const stateButtonDefault = (typeof defaultState !== "undefined" && defaultState) || "idle";
|
||||
const stateButtonAttrs = (typeof attrs !== "undefined" && attrs) || "";
|
||||
const stateButtonClass = `button lumi-state-btn ${(typeof classes !== "undefined" && classes) || ""}`.trim();
|
||||
%>
|
||||
<button
|
||||
type="<%= (typeof type !== "undefined" && type) || 'button' %>"
|
||||
class="<%= stateButtonClass %>"
|
||||
data-lumi-state-button
|
||||
data-state="<%= stateButtonDefault %>"
|
||||
data-default-state="<%= stateButtonDefault %>"
|
||||
data-loading-state="<%= (typeof loadingState !== "undefined" && loadingState) || 'loading' %>"
|
||||
data-success-state="<%= (typeof successState !== "undefined" && successState) || 'success' %>"
|
||||
data-error-state="<%= (typeof errorState !== "undefined" && errorState) || 'error' %>"
|
||||
data-reset-delay="<%= (typeof resetDelay !== "undefined" && resetDelay) || 0 %>"
|
||||
data-disable-while-busy="<%= typeof disableWhileBusy !== "undefined" && disableWhileBusy === false ? 'false' : 'true' %>"
|
||||
<%- typeof name !== "undefined" && name ? `name="${name}"` : "" %>
|
||||
<%- typeof value !== "undefined" && value ? `value="${value}"` : "" %>
|
||||
<%- typeof ariaLabel !== "undefined" && ariaLabel ? `aria-label="${ariaLabel}"` : "" %>
|
||||
<%- typeof disabled !== "undefined" && disabled ? "disabled" : "" %>
|
||||
<%- stateButtonAttrs %>
|
||||
>
|
||||
<span class="lumi-state-btn-content">
|
||||
<% stateButtonStates.forEach((state) => { %>
|
||||
<span data-state-view="<%= state.id %>" <%= state.id === stateButtonDefault ? "" : "hidden" %>>
|
||||
<% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %>
|
||||
<span><%= state.text %></span>
|
||||
</span>
|
||||
<% }) %>
|
||||
</span>
|
||||
</button>
|
||||
@ -27,6 +27,12 @@
|
||||
--lumi-radius: <%= theme.metrics.radius %>px;
|
||||
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
|
||||
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
|
||||
--lumi-font-body: <%- theme.typography.bodyFontStack %>;
|
||||
--lumi-font-display: <%- theme.typography.displayFontStack %>;
|
||||
--lumi-font-mono: <%- theme.typography.monoFontStack %>;
|
||||
--lumi-font-size-base: <%= theme.typography.baseSize %>px;
|
||||
--lumi-heading-scale: <%= theme.typography.headingScale %>;
|
||||
--lumi-control-scale: <%= theme.typography.controlScale %>;
|
||||
--role-public: <%= theme.role.public %>;
|
||||
--role-mod: <%= theme.role.mod %>;
|
||||
--role-admin: <%= theme.role.admin %>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user