feat: add visual customization presets to Settings > Appearance tab
Some checks failed
Lint & Test / test (push) Failing after 30s
Some checks failed
Lint & Test / test (push) Failing after 30s
Add style presets (font + color combinations) and background effect presets as a new Appearance tab in the settings modal. Style presets include Default, Midnight, Ember, Arctic, Terminal, and Neon — each with curated dark/light theme colors and Google Font pairings. Background effects (Dot Grid, Gradient Mesh, Scanlines, Particles) use a dedicated overlay div alongside the existing WebGL Noise Field. All choices persist to localStorage and restore on page load.
This commit is contained in:
@@ -14,4 +14,5 @@
|
|||||||
@import './tree-nav.css';
|
@import './tree-nav.css';
|
||||||
@import './tutorials.css';
|
@import './tutorials.css';
|
||||||
@import './graph-editor.css';
|
@import './graph-editor.css';
|
||||||
|
@import './appearance.css';
|
||||||
@import './mobile.css';
|
@import './mobile.css';
|
||||||
|
|||||||
300
server/src/wled_controller/static/css/appearance.css
Normal file
300
server/src/wled_controller/static/css/appearance.css
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/* ── Appearance tab: preset cards & background effects ── */
|
||||||
|
|
||||||
|
/* Use --font-body / --font-heading CSS variables for preset font switching */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body, 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-heading, 'Orbitron', sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Preset grid ─── */
|
||||||
|
|
||||||
|
.ap-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Preset card (shared) ─── */
|
||||||
|
|
||||||
|
.ap-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--duration-normal) var(--ease-out),
|
||||||
|
box-shadow var(--duration-normal) var(--ease-out),
|
||||||
|
transform var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card:hover {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary-color),
|
||||||
|
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card.active::after {
|
||||||
|
content: '\2713';
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card.active .ap-card-label {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Style preset preview ─── */
|
||||||
|
|
||||||
|
.ap-card-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid;
|
||||||
|
padding: 8px 7px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card-accent {
|
||||||
|
width: 24px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card-lines span {
|
||||||
|
display: block;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Background effect preview ─── */
|
||||||
|
|
||||||
|
.ap-bg-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-bg-preview-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini previews for each effect type */
|
||||||
|
[data-effect="none"] .ap-bg-preview-inner {
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-effect="noise"] .ap-bg-preview-inner {
|
||||||
|
background: radial-gradient(ellipse at 30% 50%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 20%, var(--bg-color)) 0%,
|
||||||
|
var(--bg-color) 70%);
|
||||||
|
animation: ap-noise-shimmer 4s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ap-noise-shimmer {
|
||||||
|
from { opacity: 0.7; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-effect="grid"] .ap-bg-preview-inner {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, var(--text-muted) 0.5px, transparent 0.5px);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-effect="mesh"] .ap-bg-preview-inner {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 30%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse at 80% 70%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-effect="scanlines"] .ap-bg-preview-inner {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 2px,
|
||||||
|
color-mix(in srgb, var(--text-muted) 10%, transparent) 2px,
|
||||||
|
color-mix(in srgb, var(--text-muted) 10%, transparent) 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-effect="particles"] .ap-bg-preview-inner {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 2px at 20% 40%, var(--primary-color) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 1.5px at 60% 25%, var(--primary-color) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 2px at 75% 65%, var(--primary-color) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 1px at 40% 80%, var(--primary-color) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 1.5px at 90% 35%, var(--primary-color) 0%, transparent 100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Full-page background effects ═══
|
||||||
|
Uses a dedicated <div id="bg-effect-layer"> (same pattern as the WebGL canvas).
|
||||||
|
The active effect class (e.g. .bg-effect-grid) is set directly on the div. */
|
||||||
|
|
||||||
|
/* When a CSS bg effect is active, make body transparent so the layer shows through
|
||||||
|
(mirrors [data-bg-anim="on"] body { background: transparent } in base.css) */
|
||||||
|
[data-bg-effect] body {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bg-effect] header {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bg-effect] header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
background: color-mix(in srgb, var(--bg-color) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card translucency for CSS bg effects (match existing bg-anim behaviour) */
|
||||||
|
[data-bg-effect][data-theme="dark"] .card,
|
||||||
|
[data-bg-effect][data-theme="dark"] .template-card,
|
||||||
|
[data-bg-effect][data-theme="dark"] .add-device-card,
|
||||||
|
[data-bg-effect][data-theme="dark"] .dashboard-target {
|
||||||
|
background: rgba(45, 45, 45, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bg-effect][data-theme="light"] .card,
|
||||||
|
[data-bg-effect][data-theme="light"] .template-card,
|
||||||
|
[data-bg-effect][data-theme="light"] .add-device-card,
|
||||||
|
[data-bg-effect][data-theme="light"] .dashboard-target {
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bg-effect-layer {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bg-effect-layer.bg-effect-grid,
|
||||||
|
#bg-effect-layer.bg-effect-mesh,
|
||||||
|
#bg-effect-layer.bg-effect-scanlines,
|
||||||
|
#bg-effect-layer.bg-effect-particles {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Grid: dot matrix ── */
|
||||||
|
#bg-effect-layer.bg-effect-grid {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle 1px, var(--text-secondary) 0%, transparent 100%);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient mesh: ambient blobs ── */
|
||||||
|
#bg-effect-layer.bg-effect-mesh {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 600px 400px at 15% 20%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(ellipse 500px 500px at 85% 80%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(ellipse 400px 300px at 60% 40%,
|
||||||
|
color-mix(in srgb, var(--primary-color) 14%, transparent) 0%, transparent 100%);
|
||||||
|
animation: bg-mesh-drift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-mesh-drift {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(-20px, 15px) scale(1.05); }
|
||||||
|
100% { transform: translate(10px, -10px) scale(0.98); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scanlines: retro CRT ── */
|
||||||
|
#bg-effect-layer.bg-effect-scanlines {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 3px,
|
||||||
|
color-mix(in srgb, var(--text-muted) 8%, transparent) 3px,
|
||||||
|
color-mix(in srgb, var(--text-muted) 8%, transparent) 4px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CSS Particles: glowing dots ── */
|
||||||
|
#bg-effect-layer.bg-effect-particles {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 40px at 10% 20%, color-mix(in srgb, var(--primary-color) 30%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 25px at 30% 70%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 50px at 55% 15%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 30px at 70% 55%, color-mix(in srgb, var(--primary-color) 28%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 35px at 85% 35%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 20px at 45% 45%, color-mix(in srgb, var(--primary-color) 32%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 45px at 20% 85%, color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 28px at 92% 78%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 15px at 65% 90%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 100%),
|
||||||
|
radial-gradient(circle 35px at 5% 50%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%);
|
||||||
|
animation: bg-particles-float 30s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-particles-float {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
33% { transform: translate(15px, -20px); }
|
||||||
|
66% { transform: translate(-10px, 10px); }
|
||||||
|
100% { transform: translate(5px, -5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile: 2-column grid ─── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ap-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
423
server/src/wled_controller/static/js/features/appearance.ts
Normal file
423
server/src/wled_controller/static/js/features/appearance.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* Appearance — style presets (font + colors) and background effect presets.
|
||||||
|
*
|
||||||
|
* Persists choices to localStorage. Style presets override CSS variables;
|
||||||
|
* background effects toggle CSS classes on <html>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StylePreset {
|
||||||
|
readonly id: string;
|
||||||
|
/** i18n key for the display name */
|
||||||
|
readonly nameKey: string;
|
||||||
|
/** Body font-family stack */
|
||||||
|
readonly fontBody: string;
|
||||||
|
/** Heading font-family (h1 logo) */
|
||||||
|
readonly fontHeading: string;
|
||||||
|
/** Primary accent color */
|
||||||
|
readonly accent: string;
|
||||||
|
/** Dark-theme overrides (applied when data-theme="dark") */
|
||||||
|
readonly dark: ThemeVars;
|
||||||
|
/** Light-theme overrides (applied when data-theme="light") */
|
||||||
|
readonly light: ThemeVars;
|
||||||
|
/** Google Fonts URL to load (empty = use local fonts only) */
|
||||||
|
readonly fontUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeVars {
|
||||||
|
readonly bgColor: string;
|
||||||
|
readonly bgSecondary: string;
|
||||||
|
readonly cardBg: string;
|
||||||
|
readonly textColor: string;
|
||||||
|
readonly textSecondary: string;
|
||||||
|
readonly textMuted: string;
|
||||||
|
readonly borderColor: string;
|
||||||
|
readonly inputBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BgEffectPreset {
|
||||||
|
readonly id: string;
|
||||||
|
readonly nameKey: string;
|
||||||
|
/** CSS class added to <html>. '' = no effect. */
|
||||||
|
readonly cssClass: string;
|
||||||
|
/** For the WebGL noise field, we reuse the existing data-bg-anim attr */
|
||||||
|
readonly useBgAnim: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Style preset definitions ───────────────────────────────
|
||||||
|
|
||||||
|
const STYLE_PRESETS: readonly StylePreset[] = [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
nameKey: 'appearance.preset.default',
|
||||||
|
fontBody: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
fontHeading: "'Orbitron', sans-serif",
|
||||||
|
accent: '#4CAF50',
|
||||||
|
fontUrl: '',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#1a1a1a', bgSecondary: '#242424', cardBg: '#2d2d2d',
|
||||||
|
textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777',
|
||||||
|
borderColor: '#404040', inputBg: '#1a1a2e',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#f5f5f5', bgSecondary: '#eee', cardBg: '#ffffff',
|
||||||
|
textColor: '#333333', textSecondary: '#595959', textMuted: '#767676',
|
||||||
|
borderColor: '#e0e0e0', inputBg: '#f0f0f0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'midnight',
|
||||||
|
nameKey: 'appearance.preset.midnight',
|
||||||
|
fontBody: "'IBM Plex Sans', 'DM Sans', -apple-system, sans-serif",
|
||||||
|
fontHeading: "'Orbitron', sans-serif",
|
||||||
|
accent: '#7C4DFF',
|
||||||
|
fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;600;700&display=swap',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#0f0e1a', bgSecondary: '#181627', cardBg: '#1e1c30',
|
||||||
|
textColor: '#d4d0f0', textSecondary: '#9590b8', textMuted: '#6b6790',
|
||||||
|
borderColor: '#2e2b4a', inputBg: '#141225',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#f0eef8', bgSecondary: '#e5e2f0', cardBg: '#ffffff',
|
||||||
|
textColor: '#2a2640', textSecondary: '#5c5878', textMuted: '#8884a0',
|
||||||
|
borderColor: '#d0cde0', inputBg: '#eae8f2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ember',
|
||||||
|
nameKey: 'appearance.preset.ember',
|
||||||
|
fontBody: "'Space Grotesk', 'DM Sans', -apple-system, sans-serif",
|
||||||
|
fontHeading: "'Orbitron', sans-serif",
|
||||||
|
accent: '#FF6D00',
|
||||||
|
fontUrl: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#1a1410', bgSecondary: '#241c14', cardBg: '#2c221a',
|
||||||
|
textColor: '#e8ddd0', textSecondary: '#a89880', textMuted: '#7a6e5e',
|
||||||
|
borderColor: '#3d3328', inputBg: '#1e1812',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#faf5ee', bgSecondary: '#f0e8da', cardBg: '#ffffff',
|
||||||
|
textColor: '#3a2e20', textSecondary: '#6e5e48', textMuted: '#998870',
|
||||||
|
borderColor: '#e0d4c2', inputBg: '#f5efe4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arctic',
|
||||||
|
nameKey: 'appearance.preset.arctic',
|
||||||
|
fontBody: "'Inter', 'DM Sans', -apple-system, sans-serif",
|
||||||
|
fontHeading: "'Orbitron', sans-serif",
|
||||||
|
accent: '#0091EA',
|
||||||
|
fontUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#0e1820', bgSecondary: '#142028', cardBg: '#1a2830',
|
||||||
|
textColor: '#d0e4f0', textSecondary: '#88a8c0', textMuted: '#607888',
|
||||||
|
borderColor: '#283848', inputBg: '#121e28',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#f0f6fa', bgSecondary: '#e4eef4', cardBg: '#ffffff',
|
||||||
|
textColor: '#1a3040', textSecondary: '#4a6a80', textMuted: '#7898a8',
|
||||||
|
borderColor: '#d0dfe8', inputBg: '#e8f0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'terminal',
|
||||||
|
nameKey: 'appearance.preset.terminal',
|
||||||
|
fontBody: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace",
|
||||||
|
fontHeading: "'JetBrains Mono', monospace",
|
||||||
|
accent: '#00E676',
|
||||||
|
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#0a0a0a', bgSecondary: '#111111', cardBg: '#161616',
|
||||||
|
textColor: '#c8e6c9', textSecondary: '#6a9b6e', textMuted: '#4a7a4e',
|
||||||
|
borderColor: '#1e3a1e', inputBg: '#0d0d0d',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#f0f8f0', bgSecondary: '#e0f0e0', cardBg: '#ffffff',
|
||||||
|
textColor: '#1a2e1a', textSecondary: '#3a6a3a', textMuted: '#5a8a5a',
|
||||||
|
borderColor: '#c0dcc0', inputBg: '#e8f2e8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neon',
|
||||||
|
nameKey: 'appearance.preset.neon',
|
||||||
|
fontBody: "'Exo 2', 'DM Sans', -apple-system, sans-serif",
|
||||||
|
fontHeading: "'Orbitron', sans-serif",
|
||||||
|
accent: '#FF1493',
|
||||||
|
fontUrl: 'https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700&display=swap',
|
||||||
|
dark: {
|
||||||
|
bgColor: '#08060e', bgSecondary: '#100d18', cardBg: '#14101e',
|
||||||
|
textColor: '#e8d8f0', textSecondary: '#a888c0', textMuted: '#785898',
|
||||||
|
borderColor: '#281e40', inputBg: '#0c0810',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
bgColor: '#faf0f8', bgSecondary: '#f0e4ee', cardBg: '#ffffff',
|
||||||
|
textColor: '#30182a', textSecondary: '#704060', textMuted: '#a07090',
|
||||||
|
borderColor: '#e0c8d8', inputBg: '#f5eaf2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Background effect definitions ──────────────────────────
|
||||||
|
|
||||||
|
const BG_EFFECT_PRESETS: readonly BgEffectPreset[] = [
|
||||||
|
{ id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false },
|
||||||
|
{ id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true },
|
||||||
|
{ id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false },
|
||||||
|
{ id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false },
|
||||||
|
{ id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false },
|
||||||
|
{ id: 'particles', nameKey: 'appearance.bg.particles', cssClass: 'bg-effect-particles', useBgAnim: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Persistence keys ───────────────────────────────────────
|
||||||
|
|
||||||
|
const LS_STYLE_PRESET = 'stylePreset';
|
||||||
|
const LS_BG_EFFECT = 'bgEffect';
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _activeStyleId = 'default';
|
||||||
|
let _activeBgEffectId = 'none';
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Get all style presets (for rendering the picker). */
|
||||||
|
export function getStylePresets(): readonly StylePreset[] {
|
||||||
|
return STYLE_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all background effect presets. */
|
||||||
|
export function getBgEffectPresets(): readonly BgEffectPreset[] {
|
||||||
|
return BG_EFFECT_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a style preset by ID. Persists to localStorage. */
|
||||||
|
export function applyStylePreset(id: string): void {
|
||||||
|
const preset = STYLE_PRESETS.find(p => p.id === id);
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
_activeStyleId = id;
|
||||||
|
localStorage.setItem(LS_STYLE_PRESET, id);
|
||||||
|
|
||||||
|
// Load Google Font if needed
|
||||||
|
_ensureFont(preset.fontUrl, preset.id);
|
||||||
|
|
||||||
|
// Apply font families
|
||||||
|
document.documentElement.style.setProperty('--font-body', preset.fontBody);
|
||||||
|
document.documentElement.style.setProperty('--font-heading', preset.fontHeading);
|
||||||
|
|
||||||
|
// Apply accent color via existing mechanism
|
||||||
|
const applyAccent = (window as any).applyAccentColor;
|
||||||
|
if (typeof applyAccent === 'function') {
|
||||||
|
applyAccent(preset.accent, true);
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.setProperty('--primary-color', preset.accent);
|
||||||
|
localStorage.setItem('accentColor', preset.accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme-specific color overrides
|
||||||
|
_applyThemeVars(preset);
|
||||||
|
|
||||||
|
// Update UI selection state
|
||||||
|
_updatePresetSelection('style', id);
|
||||||
|
|
||||||
|
showToast(t('appearance.preset.applied'), 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a background effect by ID. Persists to localStorage. */
|
||||||
|
export function applyBgEffect(id: string): void {
|
||||||
|
const effect = BG_EFFECT_PRESETS.find(e => e.id === id);
|
||||||
|
if (!effect) return;
|
||||||
|
|
||||||
|
_activeBgEffectId = id;
|
||||||
|
localStorage.setItem(LS_BG_EFFECT, id);
|
||||||
|
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
// Remove all CSS effect classes from the dedicated layer element
|
||||||
|
const layer = document.getElementById('bg-effect-layer');
|
||||||
|
if (layer) {
|
||||||
|
BG_EFFECT_PRESETS.forEach(e => {
|
||||||
|
if (e.cssClass) layer.classList.remove(e.cssClass);
|
||||||
|
});
|
||||||
|
if (effect.cssClass) layer.classList.add(effect.cssClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set data-bg-effect on <html> so CSS can make body transparent
|
||||||
|
if (effect.cssClass) {
|
||||||
|
html.setAttribute('data-bg-effect', effect.id);
|
||||||
|
} else {
|
||||||
|
html.removeAttribute('data-bg-effect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle WebGL bg-anim (only for the "noise" effect)
|
||||||
|
html.setAttribute('data-bg-anim', effect.useBgAnim ? 'on' : 'off');
|
||||||
|
localStorage.setItem('bgAnim', effect.useBgAnim ? 'on' : 'off');
|
||||||
|
|
||||||
|
// Update header toggle button (lit when any effect is active)
|
||||||
|
const hasEffect = effect.useBgAnim || !!effect.cssClass;
|
||||||
|
const bgAnimBtn = document.getElementById('bg-anim-btn');
|
||||||
|
if (bgAnimBtn) bgAnimBtn.style.opacity = hasEffect ? '1' : '0.5';
|
||||||
|
|
||||||
|
_updatePresetSelection('bg', id);
|
||||||
|
|
||||||
|
showToast(t('appearance.bg.applied'), 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore saved presets on page load. Called from init. */
|
||||||
|
export function initAppearance(): void {
|
||||||
|
_activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default';
|
||||||
|
_activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'none';
|
||||||
|
|
||||||
|
// Apply style preset silently (without toast)
|
||||||
|
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
|
||||||
|
if (preset && preset.id !== 'default') {
|
||||||
|
_ensureFont(preset.fontUrl, preset.id);
|
||||||
|
document.documentElement.style.setProperty('--font-body', preset.fontBody);
|
||||||
|
document.documentElement.style.setProperty('--font-heading', preset.fontHeading);
|
||||||
|
_applyThemeVars(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply background effect silently on the dedicated layer element
|
||||||
|
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
|
||||||
|
if (effect) {
|
||||||
|
const layer = document.getElementById('bg-effect-layer');
|
||||||
|
if (layer && effect.cssClass) layer.classList.add(effect.cssClass);
|
||||||
|
if (effect.cssClass) {
|
||||||
|
document.documentElement.setAttribute('data-bg-effect', effect.id);
|
||||||
|
}
|
||||||
|
if (effect.useBgAnim) {
|
||||||
|
document.documentElement.setAttribute('data-bg-anim', 'on');
|
||||||
|
localStorage.setItem('bgAnim', 'on');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the Appearance tab content. Called when the tab is switched to. */
|
||||||
|
export function renderAppearanceTab(): void {
|
||||||
|
const panel = document.getElementById('settings-panel-appearance');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
// Don't re-render if already populated
|
||||||
|
if (panel.querySelector('.appearance-presets')) {
|
||||||
|
_updatePresetSelection('style', _activeStyleId);
|
||||||
|
_updatePresetSelection('bg', _activeBgEffectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Style presets section ──
|
||||||
|
const styleHtml = STYLE_PRESETS.map(p => {
|
||||||
|
const active = p.id === _activeStyleId ? ' active' : '';
|
||||||
|
return `<button class="ap-card${active}" data-preset-type="style" data-preset-id="${p.id}" onclick="applyStylePreset('${p.id}')">
|
||||||
|
<div class="ap-card-preview" style="background:${p.dark.bgColor};border-color:${p.dark.borderColor}">
|
||||||
|
<div class="ap-card-accent" style="background:${p.accent}"></div>
|
||||||
|
<div class="ap-card-lines">
|
||||||
|
<span style="background:${p.dark.textColor}"></span>
|
||||||
|
<span style="background:${p.dark.textSecondary};width:70%"></span>
|
||||||
|
<span style="background:${p.dark.textMuted};width:45%"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="ap-card-label" data-i18n="${p.nameKey}">${t(p.nameKey)}</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// ── Background effects section ──
|
||||||
|
const bgHtml = BG_EFFECT_PRESETS.map(e => {
|
||||||
|
const active = e.id === _activeBgEffectId ? ' active' : '';
|
||||||
|
return `<button class="ap-card ap-card-bg${active}" data-preset-type="bg" data-preset-id="${e.id}" onclick="applyBgEffect('${e.id}')">
|
||||||
|
<div class="ap-bg-preview" data-effect="${e.id}">
|
||||||
|
<div class="ap-bg-preview-inner"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ap-card-label" data-i18n="${e.nameKey}">${t(e.nameKey)}</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="appearance-presets">
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label>
|
||||||
|
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
|
||||||
|
<div class="ap-grid">${styleHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:1rem">
|
||||||
|
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label>
|
||||||
|
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
|
||||||
|
<div class="ap-grid">${bgHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active style preset ID. */
|
||||||
|
export function getActiveStylePreset(): string {
|
||||||
|
return _activeStyleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active bg effect preset ID. */
|
||||||
|
export function getActiveBgEffect(): string {
|
||||||
|
return _activeBgEffectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Apply theme color CSS variables for the current active theme (dark/light). */
|
||||||
|
function _applyThemeVars(preset: StylePreset): void {
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
const vars = theme === 'dark' ? preset.dark : preset.light;
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
|
||||||
|
root.setProperty('--bg-color', vars.bgColor);
|
||||||
|
root.setProperty('--bg-secondary', vars.bgSecondary);
|
||||||
|
root.setProperty('--card-bg', vars.cardBg);
|
||||||
|
root.setProperty('--text-color', vars.textColor);
|
||||||
|
root.setProperty('--text-primary', vars.textColor);
|
||||||
|
root.setProperty('--text-secondary', vars.textSecondary);
|
||||||
|
root.setProperty('--text-muted', vars.textMuted);
|
||||||
|
root.setProperty('--border-color', vars.borderColor);
|
||||||
|
root.setProperty('--input-bg', vars.inputBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure a Google Font stylesheet is loaded (idempotent). */
|
||||||
|
function _ensureFont(url: string, id: string): void {
|
||||||
|
if (!url) return;
|
||||||
|
const linkId = `gfont-${id}`;
|
||||||
|
if (document.getElementById(linkId)) return;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.id = linkId;
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = url;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the visual selection ring on preset cards. */
|
||||||
|
function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void {
|
||||||
|
const attr = type === 'style' ? 'style' : 'bg';
|
||||||
|
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
|
||||||
|
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Listen for theme changes to reapply preset colors ──────
|
||||||
|
|
||||||
|
document.addEventListener('themeChanged', () => {
|
||||||
|
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
|
||||||
|
if (preset && preset.id !== 'default') {
|
||||||
|
_applyThemeVars(preset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also listen via MutationObserver on data-theme attribute
|
||||||
|
const _themeObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
if (m.attributeName === 'data-theme') {
|
||||||
|
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
|
||||||
|
if (preset && preset.id !== 'default') {
|
||||||
|
_applyThemeVars(preset);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
@@ -72,6 +72,10 @@ export function switchSettingsTab(tabId: string): void {
|
|||||||
document.querySelectorAll('.settings-panel').forEach(panel => {
|
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||||
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
||||||
});
|
});
|
||||||
|
// Lazy-render the appearance tab content
|
||||||
|
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
|
||||||
|
window.renderAppearanceTab();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Log Viewer ────────────────────────────────────────────
|
// ─── Log Viewer ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
"settings.tab.general": "General",
|
"settings.tab.general": "General",
|
||||||
"settings.tab.backup": "Backup",
|
"settings.tab.backup": "Backup",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.tab.appearance": "Appearance",
|
||||||
"settings.logs.open_viewer": "Open Log Viewer",
|
"settings.logs.open_viewer": "Open Log Viewer",
|
||||||
"settings.external_url.label": "External URL",
|
"settings.external_url.label": "External URL",
|
||||||
"settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080",
|
"settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080",
|
||||||
@@ -1740,6 +1741,25 @@
|
|||||||
"bulk.disable": "Disable",
|
"bulk.disable": "Disable",
|
||||||
"bulk.confirm_delete.one": "Delete {count} item?",
|
"bulk.confirm_delete.one": "Delete {count} item?",
|
||||||
"bulk.confirm_delete.other": "Delete {count} items?",
|
"bulk.confirm_delete.other": "Delete {count} items?",
|
||||||
|
"appearance.style.label": "Style Presets",
|
||||||
|
"appearance.style.hint": "Choose a visual theme — font pairing and color palette applied together.",
|
||||||
|
"appearance.preset.default": "Default",
|
||||||
|
"appearance.preset.midnight": "Midnight",
|
||||||
|
"appearance.preset.ember": "Ember",
|
||||||
|
"appearance.preset.arctic": "Arctic",
|
||||||
|
"appearance.preset.terminal": "Terminal",
|
||||||
|
"appearance.preset.neon": "Neon",
|
||||||
|
"appearance.preset.applied": "Style preset applied",
|
||||||
|
"appearance.bg.label": "Background Effects",
|
||||||
|
"appearance.bg.hint": "Add an ambient background layer behind the interface.",
|
||||||
|
"appearance.bg.none": "None",
|
||||||
|
"appearance.bg.noise": "Noise Field",
|
||||||
|
"appearance.bg.grid": "Dot Grid",
|
||||||
|
"appearance.bg.mesh": "Gradient Mesh",
|
||||||
|
"appearance.bg.scanlines": "Scanlines",
|
||||||
|
"appearance.bg.particles": "Particles",
|
||||||
|
"appearance.bg.applied": "Background effect applied",
|
||||||
|
|
||||||
"color_strip": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "Search notification apps…"
|
"search_apps": "Search notification apps…"
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
"settings.tab.general": "Основные",
|
"settings.tab.general": "Основные",
|
||||||
"settings.tab.backup": "Бэкап",
|
"settings.tab.backup": "Бэкап",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.tab.appearance": "Оформление",
|
||||||
"settings.logs.open_viewer": "Открыть логи",
|
"settings.logs.open_viewer": "Открыть логи",
|
||||||
"settings.external_url.label": "Внешний URL",
|
"settings.external_url.label": "Внешний URL",
|
||||||
"settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080",
|
"settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080",
|
||||||
@@ -1742,6 +1743,25 @@
|
|||||||
"bulk.confirm_delete.one": "Удалить {count} элемент?",
|
"bulk.confirm_delete.one": "Удалить {count} элемент?",
|
||||||
"bulk.confirm_delete.few": "Удалить {count} элемента?",
|
"bulk.confirm_delete.few": "Удалить {count} элемента?",
|
||||||
"bulk.confirm_delete.many": "Удалить {count} элементов?",
|
"bulk.confirm_delete.many": "Удалить {count} элементов?",
|
||||||
|
"appearance.style.label": "Стили оформления",
|
||||||
|
"appearance.style.hint": "Выберите визуальную тему — шрифт и цветовая палитра применяются вместе.",
|
||||||
|
"appearance.preset.default": "Стандарт",
|
||||||
|
"appearance.preset.midnight": "Полночь",
|
||||||
|
"appearance.preset.ember": "Угли",
|
||||||
|
"appearance.preset.arctic": "Арктика",
|
||||||
|
"appearance.preset.terminal": "Терминал",
|
||||||
|
"appearance.preset.neon": "Неон",
|
||||||
|
"appearance.preset.applied": "Стиль применён",
|
||||||
|
"appearance.bg.label": "Фоновые эффекты",
|
||||||
|
"appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.",
|
||||||
|
"appearance.bg.none": "Нет",
|
||||||
|
"appearance.bg.noise": "Шумовое поле",
|
||||||
|
"appearance.bg.grid": "Точечная сетка",
|
||||||
|
"appearance.bg.mesh": "Градиент",
|
||||||
|
"appearance.bg.scanlines": "Развёртка",
|
||||||
|
"appearance.bg.particles": "Частицы",
|
||||||
|
"appearance.bg.applied": "Фоновый эффект применён",
|
||||||
|
|
||||||
"color_strip": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "Поиск приложений…"
|
"search_apps": "Поиск приложений…"
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
"settings.tab.general": "常规",
|
"settings.tab.general": "常规",
|
||||||
"settings.tab.backup": "备份",
|
"settings.tab.backup": "备份",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
|
"settings.tab.appearance": "外观",
|
||||||
"settings.logs.open_viewer": "打开日志查看器",
|
"settings.logs.open_viewer": "打开日志查看器",
|
||||||
"settings.external_url.label": "外部 URL",
|
"settings.external_url.label": "外部 URL",
|
||||||
"settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080",
|
"settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080",
|
||||||
@@ -1740,6 +1741,25 @@
|
|||||||
"bulk.disable": "禁用",
|
"bulk.disable": "禁用",
|
||||||
"bulk.confirm_delete.one": "删除 {count} 项?",
|
"bulk.confirm_delete.one": "删除 {count} 项?",
|
||||||
"bulk.confirm_delete.other": "删除 {count} 项?",
|
"bulk.confirm_delete.other": "删除 {count} 项?",
|
||||||
|
"appearance.style.label": "样式预设",
|
||||||
|
"appearance.style.hint": "选择一个视觉主题 — 字体和配色方案一起应用。",
|
||||||
|
"appearance.preset.default": "默认",
|
||||||
|
"appearance.preset.midnight": "午夜",
|
||||||
|
"appearance.preset.ember": "余烬",
|
||||||
|
"appearance.preset.arctic": "极地",
|
||||||
|
"appearance.preset.terminal": "终端",
|
||||||
|
"appearance.preset.neon": "霓虹",
|
||||||
|
"appearance.preset.applied": "样式已应用",
|
||||||
|
"appearance.bg.label": "背景效果",
|
||||||
|
"appearance.bg.hint": "在界面后面添加环境背景层。",
|
||||||
|
"appearance.bg.none": "无",
|
||||||
|
"appearance.bg.noise": "噪声场",
|
||||||
|
"appearance.bg.grid": "点阵",
|
||||||
|
"appearance.bg.mesh": "渐变网格",
|
||||||
|
"appearance.bg.scanlines": "扫描线",
|
||||||
|
"appearance.bg.particles": "粒子",
|
||||||
|
"appearance.bg.applied": "背景效果已应用",
|
||||||
|
|
||||||
"color_strip": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "搜索通知应用…"
|
"search_apps": "搜索通知应用…"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">×</button>
|
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">×</button>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="bg-anim-canvas"></canvas>
|
<canvas id="bg-anim-canvas"></canvas>
|
||||||
|
<div id="bg-effect-layer"></div>
|
||||||
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
||||||
<div class="connection-overlay-content">
|
<div class="connection-overlay-content">
|
||||||
<div class="connection-spinner-lg"></div>
|
<div class="connection-spinner-lg"></div>
|
||||||
@@ -221,18 +222,46 @@
|
|||||||
// Initialize ambient background
|
// Initialize ambient background
|
||||||
const savedBgAnim = localStorage.getItem('bgAnim') || 'off';
|
const savedBgAnim = localStorage.getItem('bgAnim') || 'off';
|
||||||
document.documentElement.setAttribute('data-bg-anim', savedBgAnim);
|
document.documentElement.setAttribute('data-bg-anim', savedBgAnim);
|
||||||
updateBgAnimBtn(savedBgAnim);
|
|
||||||
|
// All known CSS bg-effect classes (must match appearance.ts BG_EFFECT_PRESETS)
|
||||||
|
var _bgEffectClasses = ['bg-effect-grid', 'bg-effect-mesh', 'bg-effect-scanlines', 'bg-effect-particles'];
|
||||||
|
|
||||||
function toggleBgAnim() {
|
function toggleBgAnim() {
|
||||||
const cur = document.documentElement.getAttribute('data-bg-anim');
|
var savedEffect = localStorage.getItem('bgEffect') || 'none';
|
||||||
const next = cur === 'on' ? 'off' : 'on';
|
var isOn = _isBgEffectActive();
|
||||||
document.documentElement.setAttribute('data-bg-anim', next);
|
|
||||||
localStorage.setItem('bgAnim', next);
|
if (isOn) {
|
||||||
updateBgAnimBtn(next);
|
// Turn everything off
|
||||||
|
document.documentElement.setAttribute('data-bg-anim', 'off');
|
||||||
|
document.documentElement.removeAttribute('data-bg-effect');
|
||||||
|
var lyr = document.getElementById('bg-effect-layer');
|
||||||
|
if (lyr) _bgEffectClasses.forEach(function(c) { lyr.classList.remove(c); });
|
||||||
|
updateBgAnimBtn('off');
|
||||||
|
} else {
|
||||||
|
// Restore saved effect (or fallback to WebGL noise)
|
||||||
|
if (savedEffect === 'none') savedEffect = 'noise';
|
||||||
|
if (typeof window.applyBgEffect === 'function') {
|
||||||
|
window.applyBgEffect(savedEffect);
|
||||||
|
} else {
|
||||||
|
// Fallback before bundle loads: just toggle WebGL
|
||||||
|
document.documentElement.setAttribute('data-bg-anim', 'on');
|
||||||
|
}
|
||||||
|
updateBgAnimBtn('on');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isBgEffectActive() {
|
||||||
|
if (document.documentElement.getAttribute('data-bg-anim') === 'on') return true;
|
||||||
|
var lyr = document.getElementById('bg-effect-layer');
|
||||||
|
if (!lyr) return false;
|
||||||
|
for (var i = 0; i < _bgEffectClasses.length; i++) {
|
||||||
|
if (lyr.classList.contains(_bgEffectClasses[i])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBgAnimBtn(state) {
|
function updateBgAnimBtn(state) {
|
||||||
const btn = document.getElementById('bg-anim-btn');
|
var btn = document.getElementById('bg-anim-btn');
|
||||||
if (btn) btn.style.opacity = state === 'on' ? '1' : '0.5';
|
if (btn) btn.style.opacity = state === 'on' ? '1' : '0.5';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,12 +370,18 @@
|
|||||||
const savedAccent = localStorage.getItem('accentColor');
|
const savedAccent = localStorage.getItem('accentColor');
|
||||||
if (savedAccent) applyAccentColor(savedAccent, true);
|
if (savedAccent) applyAccentColor(savedAccent, true);
|
||||||
|
|
||||||
// Early-apply saved background effect class (before bundle loads)
|
// Early-apply saved background effect class on the dedicated layer element
|
||||||
const savedBgEffect = localStorage.getItem('bgEffect');
|
const savedBgEffect = localStorage.getItem('bgEffect');
|
||||||
if (savedBgEffect && savedBgEffect !== 'none') {
|
if (savedBgEffect && savedBgEffect !== 'none' && savedBgEffect !== 'noise') {
|
||||||
const effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
|
var effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
|
||||||
if (effectClasses[savedBgEffect]) document.documentElement.classList.add(effectClasses[savedBgEffect]);
|
var layer = document.getElementById('bg-effect-layer');
|
||||||
|
if (layer && effectClasses[savedBgEffect]) {
|
||||||
|
layer.classList.add(effectClasses[savedBgEffect]);
|
||||||
|
document.documentElement.setAttribute('data-bg-effect', savedBgEffect);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Set header toggle button state (reflects both WebGL and CSS effects)
|
||||||
|
updateBgAnimBtn(_isBgEffectActive() ? 'on' : 'off');
|
||||||
|
|
||||||
// Initialize auth state
|
// Initialize auth state
|
||||||
function updateAuthUI() {
|
function updateAuthUI() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
|
||||||
<div class="modal-content" style="max-width: 480px;">
|
<div class="modal-content" style="max-width: 520px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
|
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
|
||||||
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
|
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
|
||||||
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
||||||
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
|
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
|
||||||
|
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -233,6 +234,11 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Appearance tab ═══ -->
|
||||||
|
<div id="settings-panel-appearance" class="settings-panel">
|
||||||
|
<!-- Rendered dynamically by renderAppearanceTab() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user