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
|
- `src/web/public/lumi-layout.css`: application shell, sidebar, content
|
||||||
containers, responsive grids, stacks, clusters, and mobile navigation.
|
containers, responsive grids, stacks, clusters, and mobile navigation.
|
||||||
- `src/web/public/lumi-components.css`: buttons, forms, cards, tables, lists,
|
- `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
|
- `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.
|
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.
|
- `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
|
The compact editor exposes common colors first. Advanced controls cover
|
||||||
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
|
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
|
||||||
shadow strength, and spacing scale. The server accepts only six-digit hex colors,
|
shadow strength, and spacing scale. Typography controls use constrained font
|
||||||
bounded metric values, and readable text/button/input contrast.
|
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
|
Missing or invalid stored values are replaced from the custom theme's built-in
|
||||||
base. Existing installations with modified legacy `theme_light_*`,
|
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.
|
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
|
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
|
## Visual references
|
||||||
|
|
||||||
|
|||||||
@ -55,12 +55,16 @@ function verifyThemeService() {
|
|||||||
|
|
||||||
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
|
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
|
||||||
assert.strictEqual(copy.builtin, false);
|
assert.strictEqual(copy.builtin, false);
|
||||||
|
assert.strictEqual(copy.typography.bodyFont, "lumi");
|
||||||
themes.setActiveTheme(copy.id);
|
themes.setActiveTheme(copy.id);
|
||||||
assert.strictEqual(themes.getActiveTheme().id, copy.id);
|
assert.strictEqual(themes.getActiveTheme().id, copy.id);
|
||||||
|
|
||||||
const invalid = JSON.parse(JSON.stringify(copy));
|
const invalid = JSON.parse(JSON.stringify(copy));
|
||||||
invalid.light.text = "not-a-color";
|
invalid.light.text = "not-a-color";
|
||||||
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex 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");
|
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
|
||||||
assert.strictEqual(renamed.name, "Verified Theme");
|
assert.strictEqual(renamed.name, "Verified Theme");
|
||||||
@ -80,6 +84,8 @@ function verifyThemeService() {
|
|||||||
activeTheme: renamed,
|
activeTheme: renamed,
|
||||||
themes: themes.listThemes(),
|
themes: themes.listThemes(),
|
||||||
editingTheme: renamed,
|
editingTheme: renamed,
|
||||||
|
editingBaseTheme: themes.getThemeById(renamed.baseThemeId),
|
||||||
|
fontStacks: themes.FONT_STACKS,
|
||||||
botAvatar: null,
|
botAvatar: null,
|
||||||
navSections: [],
|
navSections: [],
|
||||||
user: { username: "Admin" },
|
user: { username: "Admin" },
|
||||||
@ -90,8 +96,29 @@ function verifyThemeService() {
|
|||||||
softError: null
|
softError: null
|
||||||
}, { filename: themeView });
|
}, { filename: themeView });
|
||||||
assert(rendered.includes("data-theme-editor"));
|
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"));
|
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 statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
|
||||||
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
|
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
|
||||||
title: "Access restricted",
|
title: "Access restricted",
|
||||||
|
|||||||
@ -86,6 +86,8 @@ function ensureDefaults() {
|
|||||||
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
|
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
|
||||||
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
|
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
|
||||||
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
|
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_1: "#ffe5c4",
|
||||||
theme_light_bg_2: "#f4efe8",
|
theme_light_bg_2: "#f4efe8",
|
||||||
theme_light_bg_3: "#e9f3f1",
|
theme_light_bg_3: "#e9f3f1",
|
||||||
|
|||||||
@ -5,6 +5,30 @@ const { getSetting, setSetting } = require("./settings");
|
|||||||
const DEFAULT_THEME_ID = "builtin:lumi-default";
|
const DEFAULT_THEME_ID = "builtin:lumi-default";
|
||||||
const THEME_SYSTEM_VERSION = 1;
|
const THEME_SYSTEM_VERSION = 1;
|
||||||
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
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 = [
|
const MODE_COLOR_FIELDS = [
|
||||||
"bg1",
|
"bg1",
|
||||||
@ -98,6 +122,17 @@ const DEFAULT_THEME_VALUES = {
|
|||||||
radius: 14,
|
radius: 14,
|
||||||
shadowStrength: 0.14,
|
shadowStrength: 0.14,
|
||||||
spacingScale: 1
|
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),
|
light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light),
|
||||||
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
|
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
|
||||||
role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) },
|
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),
|
light: mergeMode(baseTheme.light, source.light),
|
||||||
dark: mergeMode(baseTheme.dark, source.dark),
|
dark: mergeMode(baseTheme.dark, source.dark),
|
||||||
role: { ...baseTheme.role, ...(source.role || {}) },
|
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"]) {
|
for (const mode of ["light", "dark"]) {
|
||||||
@ -344,6 +381,27 @@ function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
|
|||||||
return normalized;
|
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) {
|
function clampNumber(value, min, max, fallback) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) return fallback;
|
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) {
|
if (!errors.length) {
|
||||||
for (const mode of ["light", "dark"]) {
|
for (const mode of ["light", "dark"]) {
|
||||||
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
|
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
|
||||||
@ -611,7 +687,7 @@ function deleteCustomTheme(themeId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
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 mode of ["light", "dark"]) {
|
||||||
for (const field of MODE_COLOR_FIELDS) {
|
for (const field of MODE_COLOR_FIELDS) {
|
||||||
values[mode][field] = String(
|
values[mode][field] = String(
|
||||||
@ -631,14 +707,34 @@ function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
|
|||||||
values.metrics.spacingScale = Number(
|
values.metrics.spacingScale = Number(
|
||||||
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
|
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;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BUILTIN_THEMES,
|
BUILTIN_THEMES,
|
||||||
DEFAULT_THEME_ID,
|
DEFAULT_THEME_ID,
|
||||||
|
FONT_STACKS,
|
||||||
MODE_COLOR_FIELDS,
|
MODE_COLOR_FIELDS,
|
||||||
ROLE_COLOR_FIELDS,
|
ROLE_COLOR_FIELDS,
|
||||||
|
TYPOGRAPHY_FIELDS,
|
||||||
contrastRatio,
|
contrastRatio,
|
||||||
deleteCustomTheme,
|
deleteCustomTheme,
|
||||||
duplicateTheme,
|
duplicateTheme,
|
||||||
|
|||||||
@ -22,12 +22,12 @@ h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
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;
|
letter-spacing: -0.035em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
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;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,6 +185,42 @@ button:disabled {
|
|||||||
transform: none;
|
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 {
|
.icon-button {
|
||||||
width: var(--lumi-control-height);
|
width: var(--lumi-control-height);
|
||||||
height: 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) {
|
@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 {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -522,11 +572,34 @@ details > summary {
|
|||||||
.panel,
|
.panel,
|
||||||
.lumi-panel,
|
.lumi-panel,
|
||||||
.modal {
|
.modal {
|
||||||
padding: var(--lumi-space-4);
|
padding: var(--lumi-space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.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,
|
.table-tools,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--lumi-font-body);
|
font-family: var(--lumi-font-body);
|
||||||
font-size: 1rem;
|
font-size: var(--lumi-font-size-base);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
|
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
|
||||||
@ -199,6 +199,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
body.sidebar-collapsed .app-shell,
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebar-collapsed .sidebar,
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: min(19rem, 88vw);
|
width: min(19rem, 88vw);
|
||||||
padding-top: var(--lumi-space-3);
|
padding-top: var(--lumi-space-3);
|
||||||
@ -225,9 +231,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@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 {
|
.content {
|
||||||
gap: var(--lumi-space-4);
|
gap: var(--lumi-space-3);
|
||||||
padding: 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 {
|
.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;
|
BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-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-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: var(--ink, #182026);
|
||||||
--lumi-text-muted: var(--ink-soft, #5a6872);
|
--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-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-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15));
|
||||||
--lumi-transition: 150ms ease;
|
--lumi-transition: 150ms ease;
|
||||||
--lumi-control-height: 2.75rem;
|
--lumi-control-height: calc(2.75rem * var(--lumi-control-scale));
|
||||||
--lumi-content-max: 1600px;
|
--lumi-content-max: 1600px;
|
||||||
|
|
||||||
/* Compatibility aliases for existing core and plugin styles. */
|
/* Compatibility aliases for existing core and plugin styles. */
|
||||||
|
|||||||
@ -163,6 +163,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-color-control,
|
.theme-color-control,
|
||||||
|
.theme-select-control,
|
||||||
.theme-range-control {
|
.theme-range-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--lumi-space-2);
|
gap: var(--lumi-space-2);
|
||||||
@ -209,6 +210,38 @@
|
|||||||
color: var(--lumi-text-muted);
|
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 {
|
.advanced-theme-controls {
|
||||||
margin-top: var(--lumi-space-4);
|
margin-top: var(--lumi-space-4);
|
||||||
}
|
}
|
||||||
@ -247,6 +280,23 @@
|
|||||||
gap: var(--lumi-space-3);
|
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 {
|
.theme-preview-window {
|
||||||
min-height: 26rem;
|
min-height: 26rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -365,6 +415,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@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 {
|
.compact-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,15 @@
|
|||||||
shadowStrength: ["--lumi-shadow-strength", ""],
|
shadowStrength: ["--lumi-shadow-strength", ""],
|
||||||
spacingScale: ["--lumi-space-scale", ""]
|
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";
|
let previewMode = "light";
|
||||||
|
|
||||||
const updateOutputs = () => {
|
const updateOutputs = () => {
|
||||||
@ -46,6 +55,10 @@
|
|||||||
const output = input.closest("label")?.querySelector("[data-range-output]");
|
const output = input.closest("label")?.querySelector("[data-range-output]");
|
||||||
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
|
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 = () => {
|
const applyPreview = () => {
|
||||||
@ -61,10 +74,89 @@
|
|||||||
const config = metricVariables[input.dataset.themeMetric];
|
const config = metricVariables[input.dataset.themeMetric];
|
||||||
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
|
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();
|
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("input", applyPreview);
|
||||||
|
form.addEventListener("change", applyPreview);
|
||||||
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
previewMode = button.dataset.themePreviewMode;
|
previewMode = button.dataset.themePreviewMode;
|
||||||
@ -78,6 +170,40 @@
|
|||||||
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
applyPreview();
|
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", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const { getSetting, setSetting, getAllSettings } = require("../services/settings
|
|||||||
const {
|
const {
|
||||||
deleteCustomTheme,
|
deleteCustomTheme,
|
||||||
duplicateTheme,
|
duplicateTheme,
|
||||||
|
FONT_STACKS,
|
||||||
getActiveTheme,
|
getActiveTheme,
|
||||||
getThemeById,
|
getThemeById,
|
||||||
listThemes,
|
listThemes,
|
||||||
@ -112,7 +113,39 @@ function isConfigured() {
|
|||||||
return platforms.some((platform) => platform.configured);
|
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(
|
const platforms = getPlatformStatus().filter(
|
||||||
(platform) =>
|
(platform) =>
|
||||||
platform.supported && platform.enabled && platform.supportsLogin
|
platform.supported && platform.enabled && platform.supportsLogin
|
||||||
@ -123,13 +156,13 @@ function getPrimaryLoginPlatform() {
|
|||||||
return platforms.find((platform) => platform.configured) || platforms[0];
|
return platforms.find((platform) => platform.configured) || platforms[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoginRedirectPath() {
|
function getLoginRedirectPath(req) {
|
||||||
const platform = getPrimaryLoginPlatform();
|
const platform = getPrimaryLoginPlatform(req);
|
||||||
return platform?.loginPath || "/setup";
|
return platform?.loginPath || "/setup";
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireConfigured(req, res, next) {
|
function requireConfigured(req, res, next) {
|
||||||
if (!isConfigured() && !req.path.startsWith("/setup")) {
|
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
|
||||||
return res.redirect("/setup");
|
return res.redirect("/setup");
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@ -137,7 +170,7 @@ function requireConfigured(req, res, next) {
|
|||||||
|
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
return res.redirect(getLoginRedirectPath());
|
return res.redirect(getLoginRedirectPath(req));
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
@ -181,7 +214,7 @@ function formatDuration(totalMs) {
|
|||||||
function requireRole(role) {
|
function requireRole(role) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
return res.redirect(getLoginRedirectPath());
|
return res.redirect(getLoginRedirectPath(req));
|
||||||
}
|
}
|
||||||
if (!hasAccess(req.session.user, role)) {
|
if (!hasAccess(req.session.user, role)) {
|
||||||
return res.status(403).render("error", {
|
return res.status(403).render("error", {
|
||||||
@ -1943,9 +1976,14 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.locals.botAvatar = getSetting("bot_avatar_url", null);
|
res.locals.botAvatar = getSetting("bot_avatar_url", null);
|
||||||
const platformStatus = getPlatformStatus();
|
const platformStatus = getPlatformStatus();
|
||||||
res.locals.platforms = platformStatus;
|
res.locals.platforms = platformStatus;
|
||||||
res.locals.platformLogins = platformStatus.filter(
|
const localhostLogin = getLocalhostLoginPlatform(req);
|
||||||
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
|
||||||
);
|
res.locals.platformLogins = [
|
||||||
|
...(localhostLogin ? [localhostLogin] : []),
|
||||||
|
...platformStatus.filter(
|
||||||
|
(platform) => platform.supported && platform.enabled && platform.supportsLogin
|
||||||
|
)
|
||||||
|
];
|
||||||
res.locals.platformLinks = platformStatus.filter(
|
res.locals.platformLinks = platformStatus.filter(
|
||||||
(platform) => platform.supported && platform.enabled && platform.supportsLink
|
(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) => {
|
app.post("/auth/logout", (req, res) => {
|
||||||
req.session.destroy(() => {
|
req.session.destroy(() => {
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
@ -3480,6 +3565,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
settings: getAllSettings(),
|
settings: getAllSettings(),
|
||||||
platforms: getPlatformStatus(),
|
platforms: getPlatformStatus(),
|
||||||
|
localhostLoginAvailable: isLocalhostLoginAvailable(req),
|
||||||
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
|
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 platformStatus = getPlatformStatus();
|
||||||
const nextPlatformValues = new Map();
|
const nextPlatformValues = new Map();
|
||||||
for (const platform of platformStatus) {
|
for (const platform of platformStatus) {
|
||||||
@ -3516,7 +3612,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
platform.supportsLogin &&
|
platform.supportsLogin &&
|
||||||
nextPlatformValues.get(platform.id)
|
nextPlatformValues.get(platform.id)
|
||||||
);
|
);
|
||||||
if (!hasLoginPlatform) {
|
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
|
||||||
setFlash(
|
setFlash(
|
||||||
req,
|
req,
|
||||||
"error",
|
"error",
|
||||||
@ -3963,12 +4059,17 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
: activeTheme.builtin
|
: activeTheme.builtin
|
||||||
? null
|
? null
|
||||||
: activeTheme;
|
: activeTheme;
|
||||||
|
const editingBaseTheme = editingTheme && !editingTheme.builtin
|
||||||
|
? getThemeById(editingTheme.baseThemeId)
|
||||||
|
: null;
|
||||||
res.render("admin-theme", {
|
res.render("admin-theme", {
|
||||||
title: "Theming",
|
title: "Theming",
|
||||||
theme: activeTheme,
|
theme: activeTheme,
|
||||||
activeTheme,
|
activeTheme,
|
||||||
themes: listThemes(),
|
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>
|
<p class="hint">Git update checks use the configured remote and branch.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<h2>Platform Integration</h2>
|
<h2>Platform Integration</h2>
|
||||||
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||||
<div class="platform-grid">
|
<div class="platform-grid">
|
||||||
<% (platforms || []).forEach((platform) => { %>
|
<% (platforms || []).forEach((platform) => { %>
|
||||||
<div class="platform-card">
|
<div class="platform-card">
|
||||||
@ -95,11 +95,26 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button">Save settings</button>
|
<% if (localhostLoginAvailable) { %>
|
||||||
</form>
|
<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>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Navigation icons</h2>
|
<h2>Navigation icons</h2>
|
||||||
|
|||||||
@ -34,6 +34,8 @@
|
|||||||
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
|
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
|
||||||
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
|
"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">
|
<header class="page-header">
|
||||||
@ -74,7 +76,15 @@
|
|||||||
<% if (item.id !== activeTheme.id) { %>
|
<% if (item.id !== activeTheme.id) { %>
|
||||||
<form method="post" action="/admin/theming/select">
|
<form method="post" action="/admin/theming/select">
|
||||||
<input type="hidden" name="theme_id" value="<%= item.id %>" />
|
<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>
|
</form>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<span class="button subtle disabled" aria-current="true">Active</span>
|
<span class="button subtle disabled" aria-current="true">Active</span>
|
||||||
@ -92,7 +102,14 @@
|
|||||||
<span>Copy name</span>
|
<span>Copy name</span>
|
||||||
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
|
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
|
||||||
</label>
|
</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>
|
</form>
|
||||||
<% if (!item.builtin) { %>
|
<% if (!item.builtin) { %>
|
||||||
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
|
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
|
||||||
@ -100,7 +117,15 @@
|
|||||||
<span>Theme name</span>
|
<span>Theme name</span>
|
||||||
<input name="name" value="<%= item.name %>" maxlength="60" required />
|
<input name="name" value="<%= item.name %>" maxlength="60" required />
|
||||||
</label>
|
</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>
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
@ -153,6 +178,7 @@
|
|||||||
value="<%= editingTheme[mode][field] %>"
|
value="<%= editingTheme[mode][field] %>"
|
||||||
data-theme-token="<%= field %>"
|
data-theme-token="<%= field %>"
|
||||||
data-theme-mode="<%= mode %>"
|
data-theme-mode="<%= mode %>"
|
||||||
|
data-base-value="<%= baseTheme[mode][field] %>"
|
||||||
/>
|
/>
|
||||||
<output><%= editingTheme[mode][field] %></output>
|
<output><%= editingTheme[mode][field] %></output>
|
||||||
</span>
|
</span>
|
||||||
@ -172,6 +198,7 @@
|
|||||||
value="<%= editingTheme[mode][field] %>"
|
value="<%= editingTheme[mode][field] %>"
|
||||||
data-theme-token="<%= field %>"
|
data-theme-token="<%= field %>"
|
||||||
data-theme-mode="<%= mode %>"
|
data-theme-mode="<%= mode %>"
|
||||||
|
data-base-value="<%= baseTheme[mode][field] %>"
|
||||||
/>
|
/>
|
||||||
<output><%= editingTheme[mode][field] %></output>
|
<output><%= editingTheme[mode][field] %></output>
|
||||||
</span>
|
</span>
|
||||||
@ -189,26 +216,67 @@
|
|||||||
<label class="theme-color-control">
|
<label class="theme-color-control">
|
||||||
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
|
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
|
||||||
<span class="theme-color-input">
|
<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>
|
<output><%= editingTheme.role[role] %></output>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
<label class="theme-range-control">
|
<label class="theme-range-control">
|
||||||
<span>Border radius <output data-range-output><%= editingTheme.metrics.radius %>px</output></span>
|
<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>
|
||||||
<label class="theme-range-control">
|
<label class="theme-range-control">
|
||||||
<span>Shadow strength <output data-range-output><%= editingTheme.metrics.shadowStrength %></output></span>
|
<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>
|
||||||
<label class="theme-range-control">
|
<label class="theme-range-control">
|
||||||
<span>Spacing scale <output data-range-output><%= editingTheme.metrics.spacingScale %></output></span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<div class="theme-editor-actions">
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
|
<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>
|
<span class="switch-text">Apply globally after saving</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="button-group">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="theme-preview card" aria-label="Live theme preview">
|
<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-window">
|
||||||
<div class="theme-preview-nav">
|
<div class="theme-preview-nav">
|
||||||
<span class="theme-preview-logo">L</span>
|
<span class="theme-preview-logo">L</span>
|
||||||
@ -250,7 +337,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
<% } else { %>
|
<% } 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>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
|
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
|
||||||
|
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
||||||
<script src="/app.js?v=<%= assetVersion %>"></script>
|
<script src="/app.js?v=<%= assetVersion %>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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-radius: <%= theme.metrics.radius %>px;
|
||||||
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
|
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
|
||||||
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
|
--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-public: <%= theme.role.public %>;
|
||||||
--role-mod: <%= theme.role.mod %>;
|
--role-mod: <%= theme.role.mod %>;
|
||||||
--role-admin: <%= theme.role.admin %>;
|
--role-admin: <%= theme.role.admin %>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user