mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
* Add customizable theme system with style, background, and accent options
Implement comprehensive theme customization with independent settings for
dark and light modes:
- Style layer: Classic (clean shadows), Glow (accent-colored glow effects),
Vibrant (dramatic deep shadows)
- Background layer: Neutral, Warm, Cool (light mode); plus OLED, Slate,
Forest (dark mode only)
- Accent colors: Green, Teal, Blue, Orange, Purple, Red
All combinations work independently (e.g., Glow + Forest + Teal). Settings
sync across devices via database and show toast confirmations on change.
Backend:
- Add 6 new settings fields (dark_style, dark_background, dark_accent,
light_style, light_background, light_accent)
- Add integration test for theme settings API
Frontend:
- Refactor index.css with 3-layer CSS variable system
- Update ThemeContext for dual-mode theme management
- Add Appearance section to Settings page with 6 dropdowns
- Update components for new ThemeContext API
This commit is contained in:
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b3] - 2025-12-31
|
||||
|
||||
### Added
|
||||
- **Customizable Theme System** - Comprehensive theme customization with independent settings for dark and light modes:
|
||||
- **Style**: Classic (clean shadows), Glow (accent-colored glow effects), Vibrant (dramatic deep shadows)
|
||||
- **Background**: Neutral, Warm, Cool (light mode) + OLED, Slate, Forest (dark mode only)
|
||||
- **Accent Colors**: Green, Teal, Blue, Orange, Purple, Red
|
||||
- All combinations work together (e.g., Glow style + Forest background + Teal accent)
|
||||
- Settings sync across devices via database
|
||||
- Live preview in Settings → Appearance
|
||||
|
||||
### Fixed
|
||||
- **Printer hour counter** - Fixed bug in printer's hour counter display
|
||||
|
||||
### Changed
|
||||
- **Sidebar power switch** - Added confirmation modal to sidebar's quick power switch
|
||||
|
||||
## [0.1.6b2] - 2025-12-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
|
||||
**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,6 +56,18 @@ class AppSettings(BaseModel):
|
||||
virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
|
||||
virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
|
||||
|
||||
# Dark mode theme settings
|
||||
dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
|
||||
dark_background: str = Field(
|
||||
default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
|
||||
)
|
||||
dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red")
|
||||
|
||||
# Light mode theme settings
|
||||
light_style: str = Field(default="classic", description="Light mode style: classic, glow, vibrant")
|
||||
light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
|
||||
light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Schema for updating settings (all fields optional)."""
|
||||
@@ -84,3 +96,9 @@ class AppSettingsUpdate(BaseModel):
|
||||
virtual_printer_enabled: bool | None = None
|
||||
virtual_printer_access_code: str | None = None
|
||||
virtual_printer_mode: str | None = None
|
||||
dark_style: str | None = None
|
||||
dark_background: str | None = None
|
||||
dark_accent: str | None = None
|
||||
light_style: str | None = None
|
||||
light_background: str | None = None
|
||||
light_accent: str | None = None
|
||||
|
||||
@@ -53,10 +53,7 @@ class TestSettingsAPI:
|
||||
|
||||
# Update to opposite value
|
||||
new_value = not original
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"auto_archive": new_value}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["auto_archive"] == new_value
|
||||
@@ -65,10 +62,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_currency(self, async_client: AsyncClient):
|
||||
"""Verify currency can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"currency": "EUR"}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["currency"] == "EUR"
|
||||
@@ -77,10 +71,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_date_format(self, async_client: AsyncClient):
|
||||
"""Verify date format can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"date_format": "eu"}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["date_format"] == "eu"
|
||||
@@ -89,10 +80,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_time_format(self, async_client: AsyncClient):
|
||||
"""Verify time format can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"time_format": "24h"}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["time_format"] == "24h"
|
||||
@@ -101,10 +89,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_filament_cost(self, async_client: AsyncClient):
|
||||
"""Verify default filament cost can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"default_filament_cost": 30.0}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["default_filament_cost"] == 30.0
|
||||
@@ -113,10 +98,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_energy_cost(self, async_client: AsyncClient):
|
||||
"""Verify energy cost can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"energy_cost_per_kwh": 0.20}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["energy_cost_per_kwh"] == 0.20
|
||||
@@ -132,7 +114,7 @@ class TestSettingsAPI:
|
||||
"date_format": "iso",
|
||||
"time_format": "12h",
|
||||
"save_thumbnails": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -152,7 +134,7 @@ class TestSettingsAPI:
|
||||
"spoolman_enabled": True,
|
||||
"spoolman_url": "http://localhost:7912",
|
||||
"spoolman_sync_mode": "manual",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -172,7 +154,7 @@ class TestSettingsAPI:
|
||||
"ams_humidity_fair": 55,
|
||||
"ams_temp_good": 25.0,
|
||||
"ams_temp_fair": 32.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -186,10 +168,7 @@ class TestSettingsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_update_notification_language(self, async_client: AsyncClient):
|
||||
"""Verify notification language can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"notification_language": "de"}
|
||||
)
|
||||
response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["notification_language"] == "de"
|
||||
@@ -198,15 +177,37 @@ class TestSettingsAPI:
|
||||
# Settings persistence tests
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_theme_settings(self, async_client: AsyncClient):
|
||||
"""Verify theme settings can be updated."""
|
||||
response = await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={
|
||||
"dark_style": "glow",
|
||||
"dark_background": "forest",
|
||||
"dark_accent": "teal",
|
||||
"light_style": "vibrant",
|
||||
"light_background": "warm",
|
||||
"light_accent": "blue",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["dark_style"] == "glow"
|
||||
assert result["dark_background"] == "forest"
|
||||
assert result["dark_accent"] == "teal"
|
||||
assert result["light_style"] == "vibrant"
|
||||
assert result["light_background"] == "warm"
|
||||
assert result["light_accent"] == "blue"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_settings_persist_after_update(self, async_client: AsyncClient):
|
||||
"""CRITICAL: Verify settings changes persist across requests."""
|
||||
# Update settings
|
||||
await async_client.put(
|
||||
"/api/v1/settings/",
|
||||
json={"currency": "JPY", "check_updates": False}
|
||||
)
|
||||
await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False})
|
||||
|
||||
# Verify persistence in new request
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
|
||||
@@ -544,6 +544,14 @@ export interface AppSettings {
|
||||
default_printer_id: number | null;
|
||||
// Telemetry
|
||||
telemetry_enabled: boolean;
|
||||
// Dark mode theme settings
|
||||
dark_style: 'classic' | 'glow' | 'vibrant';
|
||||
dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
|
||||
dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
|
||||
// Light mode theme settings
|
||||
light_style: 'classic' | 'glow' | 'vibrant';
|
||||
light_background: 'neutral' | 'warm' | 'cool';
|
||||
light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
|
||||
@@ -52,10 +52,10 @@ export function AMSHistoryModal({
|
||||
thresholds,
|
||||
}: AMSHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const { mode: themeMode } = useTheme();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
|
||||
const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
|
||||
const isDark = theme === 'dark';
|
||||
const isDark = themeMode === 'dark';
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface AddExternalLinkModalProps {
|
||||
|
||||
export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useTheme();
|
||||
const { mode } = useTheme();
|
||||
const isEditing = !!link;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -166,7 +166,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
{useCustomIcon && customIconPreview ? (
|
||||
<img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
|
||||
<img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
|
||||
) : (
|
||||
<PresetIcon className="w-5 h-5" />
|
||||
)}
|
||||
@@ -233,7 +233,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
|
||||
/>
|
||||
{useCustomIcon && customIconPreview ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
|
||||
<img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveCustomIcon}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface CardProps {
|
||||
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
|
||||
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function setDefaultView(path: string) {
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { mode, toggleMode } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
|
||||
@@ -300,7 +300,7 @@ export function Layout() {
|
||||
<Menu className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<img
|
||||
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
||||
src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
||||
alt="Bambuddy"
|
||||
className="h-8 ml-3"
|
||||
/>
|
||||
@@ -326,7 +326,7 @@ export function Layout() {
|
||||
{/* Logo */}
|
||||
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
|
||||
<img
|
||||
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
||||
src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
||||
alt="Bambuddy"
|
||||
className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
|
||||
/>
|
||||
@@ -379,7 +379,7 @@ export function Layout() {
|
||||
<img
|
||||
src={`/api/v1/external-links/${link.id}/icon`}
|
||||
alt=""
|
||||
className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
|
||||
className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
|
||||
/>
|
||||
) : (
|
||||
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
|
||||
@@ -500,11 +500,11 @@ export function Layout() {
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
onClick={toggleMode}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Bottom row: version */}
|
||||
@@ -577,11 +577,11 @@ export function Layout() {
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
onClick={toggleMode}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) return stored;
|
||||
// Default to dark theme
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,43 +1,171 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
type ThemeStyle = 'classic' | 'glow' | 'vibrant';
|
||||
type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
|
||||
type LightBackground = 'neutral' | 'warm' | 'cool';
|
||||
type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
mode: ThemeMode;
|
||||
// Dark mode settings
|
||||
darkStyle: ThemeStyle;
|
||||
darkBackground: DarkBackground;
|
||||
darkAccent: ThemeAccent;
|
||||
// Light mode settings
|
||||
lightStyle: ThemeStyle;
|
||||
lightBackground: LightBackground;
|
||||
lightAccent: ThemeAccent;
|
||||
// Actions
|
||||
toggleMode: () => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setDarkStyle: (style: ThemeStyle) => void;
|
||||
setDarkBackground: (background: DarkBackground) => void;
|
||||
setDarkAccent: (accent: ThemeAccent) => void;
|
||||
setLightStyle: (style: ThemeStyle) => void;
|
||||
setLightBackground: (background: LightBackground) => void;
|
||||
setLightAccent: (accent: ThemeAccent) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) return stored;
|
||||
// Default to dark theme
|
||||
return 'dark';
|
||||
// Mode
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||
const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
|
||||
const legacy = localStorage.getItem('theme') as ThemeMode | null;
|
||||
return stored || legacy || 'dark';
|
||||
});
|
||||
|
||||
// Dark mode settings
|
||||
const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {
|
||||
return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic';
|
||||
});
|
||||
const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {
|
||||
return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral';
|
||||
});
|
||||
const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {
|
||||
return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';
|
||||
});
|
||||
|
||||
// Light mode settings
|
||||
const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {
|
||||
return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';
|
||||
});
|
||||
const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {
|
||||
return (localStorage.getItem('light-background') as LightBackground) || 'neutral';
|
||||
});
|
||||
const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {
|
||||
return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
|
||||
});
|
||||
|
||||
// Sync from API on mount
|
||||
useEffect(() => {
|
||||
api.getSettings().then((settings) => {
|
||||
// Dark settings
|
||||
if (settings.dark_style) {
|
||||
setDarkStyleState(settings.dark_style as ThemeStyle);
|
||||
localStorage.setItem('dark-style', settings.dark_style);
|
||||
}
|
||||
if (settings.dark_background) {
|
||||
setDarkBackgroundState(settings.dark_background as DarkBackground);
|
||||
localStorage.setItem('dark-background', settings.dark_background);
|
||||
}
|
||||
if (settings.dark_accent) {
|
||||
setDarkAccentState(settings.dark_accent as ThemeAccent);
|
||||
localStorage.setItem('dark-accent', settings.dark_accent);
|
||||
}
|
||||
// Light settings
|
||||
if (settings.light_style) {
|
||||
setLightStyleState(settings.light_style as ThemeStyle);
|
||||
localStorage.setItem('light-style', settings.light_style);
|
||||
}
|
||||
if (settings.light_background) {
|
||||
setLightBackgroundState(settings.light_background as LightBackground);
|
||||
localStorage.setItem('light-background', settings.light_background);
|
||||
}
|
||||
if (settings.light_accent) {
|
||||
setLightAccentState(settings.light_accent as ThemeAccent);
|
||||
localStorage.setItem('light-accent', settings.light_accent);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Apply theme classes based on current mode
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
// Remove all theme classes
|
||||
root.classList.remove(
|
||||
'dark',
|
||||
'style-classic', 'style-glow', 'style-vibrant',
|
||||
'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',
|
||||
'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
|
||||
);
|
||||
|
||||
// Apply based on current mode
|
||||
if (mode === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.classList.add(`style-${darkStyle}`);
|
||||
root.classList.add(`bg-${darkBackground}`);
|
||||
root.classList.add(`accent-${darkAccent}`);
|
||||
} else {
|
||||
root.classList.add(`style-${lightStyle}`);
|
||||
root.classList.add(`bg-${lightBackground}`);
|
||||
root.classList.add(`accent-${lightAccent}`);
|
||||
}
|
||||
|
||||
localStorage.setItem('theme-mode', mode);
|
||||
localStorage.removeItem('theme');
|
||||
}, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
|
||||
|
||||
const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
const setMode = (m: ThemeMode) => setModeState(m);
|
||||
|
||||
// Dark setters
|
||||
const setDarkStyle = (v: ThemeStyle) => {
|
||||
setDarkStyleState(v);
|
||||
localStorage.setItem('dark-style', v);
|
||||
api.updateSettings({ dark_style: v }).catch(() => {});
|
||||
};
|
||||
const setDarkBackground = (v: DarkBackground) => {
|
||||
setDarkBackgroundState(v);
|
||||
localStorage.setItem('dark-background', v);
|
||||
api.updateSettings({ dark_background: v }).catch(() => {});
|
||||
};
|
||||
const setDarkAccent = (v: ThemeAccent) => {
|
||||
setDarkAccentState(v);
|
||||
localStorage.setItem('dark-accent', v);
|
||||
api.updateSettings({ dark_accent: v }).catch(() => {});
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
// Light setters
|
||||
const setLightStyle = (v: ThemeStyle) => {
|
||||
setLightStyleState(v);
|
||||
localStorage.setItem('light-style', v);
|
||||
api.updateSettings({ light_style: v }).catch(() => {});
|
||||
};
|
||||
const setLightBackground = (v: LightBackground) => {
|
||||
setLightBackgroundState(v);
|
||||
localStorage.setItem('light-background', v);
|
||||
api.updateSettings({ light_background: v }).catch(() => {});
|
||||
};
|
||||
const setLightAccent = (v: ThemeAccent) => {
|
||||
setLightAccentState(v);
|
||||
localStorage.setItem('light-accent', v);
|
||||
api.updateSettings({ light_accent: v }).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider value={{
|
||||
mode,
|
||||
darkStyle, darkBackground, darkAccent,
|
||||
lightStyle, lightBackground, lightAccent,
|
||||
toggleMode, setMode,
|
||||
setDarkStyle, setDarkBackground, setDarkAccent,
|
||||
setLightStyle, setLightBackground, setLightAccent,
|
||||
}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
@@ -45,8 +173,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };
|
||||
|
||||
+190
-6
@@ -5,10 +5,10 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Bambu Lab brand colors - always the same */
|
||||
--color-bambu-green: #00ae42;
|
||||
--color-bambu-green-light: #00c64d;
|
||||
--color-bambu-green-dark: #009438;
|
||||
/* Accent colors - use CSS variables for theming */
|
||||
--color-bambu-green: var(--accent);
|
||||
--color-bambu-green-light: var(--accent-light);
|
||||
--color-bambu-green-dark: var(--accent-dark);
|
||||
|
||||
/* Theme-aware colors via CSS variables */
|
||||
--color-bambu-dark: var(--bg-primary);
|
||||
@@ -19,8 +19,17 @@
|
||||
--color-bambu-gray-dark: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Light mode (default) */
|
||||
/* ============================================
|
||||
BASE DEFAULTS
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Default accent color (green) */
|
||||
--accent: #00ae42;
|
||||
--accent-light: #00c64d;
|
||||
--accent-dark: #009438;
|
||||
|
||||
/* Default light mode background (neutral) */
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e5e5e5;
|
||||
@@ -30,6 +39,10 @@
|
||||
--text-tertiary: #808080;
|
||||
--border-color: #d4d4d4;
|
||||
|
||||
/* Default style (classic) */
|
||||
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--glow-color: transparent;
|
||||
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@@ -39,7 +52,7 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
/* Dark mode base */
|
||||
.dark {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
@@ -49,6 +62,172 @@
|
||||
--text-muted: #808080;
|
||||
--text-tertiary: #4a4a4a;
|
||||
--border-color: #3d3d3d;
|
||||
--card-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LAYER 1: BACKGROUND PALETTES
|
||||
============================================ */
|
||||
|
||||
/* Light mode backgrounds */
|
||||
.bg-neutral {
|
||||
/* Default - already set in :root */
|
||||
}
|
||||
|
||||
.bg-warm {
|
||||
--bg-primary: #faf8f5;
|
||||
--bg-secondary: #fffefa;
|
||||
--bg-tertiary: #e8e4dd;
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #5c5750;
|
||||
--text-muted: #7a756c;
|
||||
--text-tertiary: #9a9590;
|
||||
--border-color: #d8d4cc;
|
||||
}
|
||||
|
||||
.bg-cool {
|
||||
--bg-primary: #f0f4f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #dce4ec;
|
||||
--text-primary: #1a2530;
|
||||
--text-secondary: #4a5568;
|
||||
--text-muted: #6b7a8a;
|
||||
--text-tertiary: #8a9aaa;
|
||||
--border-color: #c8d4e0;
|
||||
}
|
||||
|
||||
/* Dark mode backgrounds */
|
||||
.dark.bg-neutral {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #3d3d3d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #808080;
|
||||
--text-tertiary: #4a4a4a;
|
||||
--border-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.dark.bg-warm {
|
||||
--bg-primary: #1c1a18;
|
||||
--bg-secondary: #2e2a26;
|
||||
--bg-tertiary: #3e3a36;
|
||||
--text-primary: #f5f0ea;
|
||||
--text-secondary: #b0a898;
|
||||
--text-muted: #8a8278;
|
||||
--text-tertiary: #5a5248;
|
||||
--border-color: #3e3a36;
|
||||
}
|
||||
|
||||
.dark.bg-cool {
|
||||
--bg-primary: #181c20;
|
||||
--bg-secondary: #262c32;
|
||||
--bg-tertiary: #363e46;
|
||||
--text-primary: #f0f4f8;
|
||||
--text-secondary: #98a8b8;
|
||||
--text-muted: #788898;
|
||||
--text-tertiary: #4a5a6a;
|
||||
--border-color: #363e46;
|
||||
}
|
||||
|
||||
.dark.bg-oled {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1f1f1f;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #707070;
|
||||
--text-tertiary: #404040;
|
||||
--border-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.dark.bg-slate {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-tertiary: #475569;
|
||||
--border-color: #334155;
|
||||
}
|
||||
|
||||
.dark.bg-forest {
|
||||
--bg-primary: #121a16;
|
||||
--bg-secondary: #1c2a22;
|
||||
--bg-tertiary: #2a3d30;
|
||||
--text-primary: #e8f5ec;
|
||||
--text-secondary: #8aa894;
|
||||
--text-muted: #6a8874;
|
||||
--text-tertiary: #4a6854;
|
||||
--border-color: #2a3d30;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LAYER 2: STYLE EFFECTS
|
||||
============================================ */
|
||||
|
||||
/* Classic - default, clean minimal shadows */
|
||||
.style-classic {
|
||||
/* Uses default shadows from :root and .dark */
|
||||
}
|
||||
|
||||
/* Glow - accent-colored glow effects on cards */
|
||||
.style-glow {
|
||||
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 25px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.dark.style-glow {
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Vibrant - dramatic deep shadows, more contrast */
|
||||
.style-vibrant {
|
||||
--card-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark.style-vibrant {
|
||||
--card-shadow: 0 10px 40px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LAYER 3: ACCENT COLORS
|
||||
============================================ */
|
||||
|
||||
.accent-green {
|
||||
--accent: #00ae42;
|
||||
--accent-light: #00c64d;
|
||||
--accent-dark: #009438;
|
||||
}
|
||||
|
||||
.accent-teal {
|
||||
--accent: #14b8a6;
|
||||
--accent-light: #2dd4bf;
|
||||
--accent-dark: #0d9488;
|
||||
}
|
||||
|
||||
.accent-blue {
|
||||
--accent: #3b82f6;
|
||||
--accent-light: #60a5fa;
|
||||
--accent-dark: #2563eb;
|
||||
}
|
||||
|
||||
.accent-orange {
|
||||
--accent: #f97316;
|
||||
--accent-light: #fb923c;
|
||||
--accent-dark: #ea580c;
|
||||
}
|
||||
|
||||
.accent-purple {
|
||||
--accent: #8b5cf6;
|
||||
--accent-light: #a78bfa;
|
||||
--accent-dark: #7c3aed;
|
||||
}
|
||||
|
||||
.accent-red {
|
||||
--accent: #ef4444;
|
||||
--accent-light: #f87171;
|
||||
--accent-dark: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -181,3 +360,8 @@ body {
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Card shadows - uses theme-specific shadow */
|
||||
.card-shadow {
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export function ExternalLinkPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { theme } = useTheme();
|
||||
const { mode } = useTheme();
|
||||
|
||||
const { data: link, isLoading, error } = useQuery({
|
||||
queryKey: ['external-link', id],
|
||||
@@ -35,7 +35,7 @@ export function ExternalLinkPage() {
|
||||
<iframe
|
||||
src={link.url}
|
||||
className="h-full w-full border-0"
|
||||
style={{ colorScheme: theme }}
|
||||
style={{ colorScheme: mode }}
|
||||
title={link.name}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
/>
|
||||
|
||||
@@ -43,9 +43,9 @@ import { AMSHistoryModal } from '../components/AMSHistoryModal';
|
||||
|
||||
// Nozzle side indicators (Bambu Lab style - square badge with L/R)
|
||||
function NozzleBadge({ side }: { side: 'L' | 'R' }) {
|
||||
const { theme } = useTheme();
|
||||
// Light theme: #e7f5e9 (light green), Dark theme: #1a4d2e (dark green)
|
||||
const bgColor = theme === 'dark' ? '#1a4d2e' : '#e7f5e9';
|
||||
const { mode } = useTheme();
|
||||
// Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)
|
||||
const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded"
|
||||
|
||||
@@ -21,12 +21,21 @@ import { virtualPrinterApi } from '../api/client';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { availableLanguages } from '../i18n';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
export function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showToast, showPersistentToast, dismissToast } = useToast();
|
||||
const {
|
||||
mode,
|
||||
darkStyle, darkBackground, darkAccent,
|
||||
lightStyle, lightBackground, lightAccent,
|
||||
setDarkStyle, setDarkBackground, setDarkAccent,
|
||||
setLightStyle, setLightBackground, setLightAccent,
|
||||
} = useTheme();
|
||||
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
|
||||
const [showPlugModal, setShowPlugModal] = useState(false);
|
||||
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
|
||||
@@ -586,6 +595,121 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
Appearance
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Dark Mode Settings */}
|
||||
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
|
||||
<h3 className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Dark Mode
|
||||
{mode === 'dark' && <span className="text-xs text-bambu-green">(active)</span>}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Background</label>
|
||||
<select
|
||||
value={darkBackground}
|
||||
onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="cool">Cool</option>
|
||||
<option value="oled">OLED Black</option>
|
||||
<option value="slate">Slate Blue</option>
|
||||
<option value="forest">Forest Green</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Accent</label>
|
||||
<select
|
||||
value={darkAccent}
|
||||
onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="green">Green</option>
|
||||
<option value="teal">Teal</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="orange">Orange</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="red">Red</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Style</label>
|
||||
<select
|
||||
value={darkStyle}
|
||||
onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="glow">Glow</option>
|
||||
<option value="vibrant">Vibrant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Mode Settings */}
|
||||
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
|
||||
<h3 className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Light Mode
|
||||
{mode === 'light' && <span className="text-xs text-bambu-green">(active)</span>}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Background</label>
|
||||
<select
|
||||
value={lightBackground}
|
||||
onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="cool">Cool</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Accent</label>
|
||||
<select
|
||||
value={lightAccent}
|
||||
onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="green">Green</option>
|
||||
<option value="teal">Teal</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="orange">Orange</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="red">Red</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Style</label>
|
||||
<select
|
||||
value={lightStyle}
|
||||
onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
|
||||
className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="glow">Glow</option>
|
||||
<option value="vibrant">Vibrant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Toggle between dark and light mode using the sun/moon icon in the sidebar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
|
||||
@@ -658,6 +782,10 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Second Column - Cost, AMS & Spoolman */}
|
||||
<div className="space-y-6 flex-1 lg:max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
|
||||
@@ -730,10 +858,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second Column - AMS & Spoolman */}
|
||||
<div className="space-y-6 flex-1 lg:max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CtglbIix.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BuWV4aNb.css">
|
||||
<script type="module" crossorigin src="/assets/index-VFINsn3X.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BmBv9fEm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user