diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index 5e9d6d1..6da2684 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { ThemeProvider } from './contexts/ThemeContext.jsx' import TopBar from './components/layout/TopBar.jsx' import TabNav from './components/layout/TabNav.jsx' import Overview from './pages/Overview.jsx' @@ -18,13 +19,15 @@ export default function App() { const Page = PAGES[tab] || Overview return ( -
- - -
- -
-
+ +
+ + +
+ +
+
+
) } diff --git a/dashboard/src/components/audio/ZoneCard.jsx b/dashboard/src/components/audio/ZoneCard.jsx index 901b68e..b05e9e6 100644 --- a/dashboard/src/components/audio/ZoneCard.jsx +++ b/dashboard/src/components/audio/ZoneCard.jsx @@ -81,49 +81,57 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr const styles = { card: { padding: 14, - background: 'var(--surface)', + background: 'var(--glass-bg)', + backdropFilter: 'blur(var(--glass-blur))', + WebkitBackdropFilter: 'blur(var(--glass-blur))', borderRadius: 'var(--radius)', - border: '1px solid var(--border)', + border: '1px solid rgba(255, 255, 255, 0.1)', display: 'flex', flexDirection: 'column', gap: 10, - transition: 'border-color 0.2s, opacity 0.2s', + transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)', + cursor: 'pointer', }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }, titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }, - name: { fontWeight: 600, fontSize: 14 }, + name: { fontWeight: 600, fontSize: 15, letterSpacing: '-0.01em' }, sourceTag: { - fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600, + fontSize: 11, padding: '4px 8px', borderRadius: 6, fontWeight: 600, display: 'inline-block', width: 'fit-content', + transition: 'all 0.2s', }, badges: { display: 'flex', gap: 4 }, - badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }, + badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' }, groupInfo: { fontSize: 11, color: 'var(--muted)', - background: '#38bdf811', padding: 8, borderRadius: 4, + background: 'rgba(14, 165, 233, 0.08)', padding: 8, borderRadius: 6, borderLeft: '2px solid var(--accent)', + animation: 'slideInUp 0.3s ease-out', }, - groupLabel: { fontWeight: 600, marginBottom: 4 }, + groupLabel: { fontWeight: 600, marginBottom: 4, display: 'block' }, groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }, groupZone: { - fontSize: 10, padding: '2px 6px', background: 'var(--accent)', - color: 'var(--bg)', borderRadius: 3, fontWeight: 600, + fontSize: 10, padding: '3px 8px', background: 'var(--accent)', + color: 'white', borderRadius: 4, fontWeight: 600, + animation: 'slideInUp 0.2s ease-out', }, sourceControl: { display: 'flex', gap: 8, alignItems: 'center' }, sourceSelect: { - flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)', - border: '1px solid var(--border)', borderRadius: 'var(--radius)', + flex: 1, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)', + color: 'var(--text)', + border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)', fontSize: 12, fontWeight: 600, cursor: 'pointer', + transition: 'all 0.2s', }, groupBtn: { - minWidth: 44, minHeight: 44, padding: 0, - background: 'var(--border)', color: 'var(--text)', - border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer', - fontSize: 18, transition: 'background 0.2s', - '&:hover': { background: 'var(--accent)' }, + minWidth: 40, minHeight: 40, padding: 0, + background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)', + border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)', + cursor: 'pointer', + fontSize: 16, transition: 'all 0.2s', }, volumeRow: { display: 'flex', alignItems: 'center', gap: 10 }, - muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' }, - volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' }, + muteBtn: { fontSize: 18, minWidth: 40, minHeight: 40, background: 'none', border: 'none', cursor: 'pointer', transition: 'transform 0.2s' }, + volVal: { fontFamily: 'var(--font-mono)', fontSize: 12, minWidth: 28, textAlign: 'right', color: 'var(--muted)' }, } diff --git a/dashboard/src/components/audio/ZoneGrid.jsx b/dashboard/src/components/audio/ZoneGrid.jsx index d5a0334..57f48cb 100644 --- a/dashboard/src/components/audio/ZoneGrid.jsx +++ b/dashboard/src/components/audio/ZoneGrid.jsx @@ -100,25 +100,29 @@ const styles = { gridTemplateColumns: '240px 1fr', gap: 16, height: '100%', + padding: 16, }, groupingPanel: { - background: 'var(--surface)', - borderRadius: 'var(--radius)', - border: '1px solid var(--border)', - padding: 12, + background: 'var(--glass-bg)', + backdropFilter: 'blur(var(--glass-blur))', + WebkitBackdropFilter: 'blur(var(--glass-blur))', + borderRadius: 'var(--radius-lg)', + border: '1px solid rgba(255, 255, 255, 0.1)', + padding: 14, display: 'flex', flexDirection: 'column', gap: 10, - maxHeight: '100vh', + maxHeight: 'calc(100vh - 200px)', overflowY: 'auto', + animation: 'slideInDown 0.3s ease-out', }, panelTitle: { - fontSize: 12, + fontSize: 11, fontWeight: 700, color: 'var(--muted)', margin: '0 0 8px 0', textTransform: 'uppercase', - letterSpacing: 0.5, + letterSpacing: 0.6, }, emptyText: { fontSize: 11, @@ -132,13 +136,14 @@ const styles = { gap: 6, }, groupListItem: { - background: '#38bdf811', - border: '1px solid var(--accent)', - borderRadius: 4, + background: 'rgba(14, 165, 233, 0.08)', + border: '1px solid rgba(14, 165, 233, 0.2)', + borderRadius: 6, padding: '8px 10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', + transition: 'all 0.2s', }, groupName: { fontSize: 11, @@ -155,11 +160,13 @@ const styles = { padding: '0 4px', minWidth: 24, minHeight: 24, + transition: 'transform 0.2s', }, grid: { display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, overflowY: 'auto', + animation: 'slideInUp 0.3s ease-out', } } diff --git a/dashboard/src/components/layout/TabNav.jsx b/dashboard/src/components/layout/TabNav.jsx index f4e6afd..e826159 100644 --- a/dashboard/src/components/layout/TabNav.jsx +++ b/dashboard/src/components/layout/TabNav.jsx @@ -16,6 +16,7 @@ export default function TabNav({ activeTab, onTabChange }) { ...(activeTab === tab.id ? styles.active : {}), }} onClick={() => onTabChange(tab.id)} + className={activeTab === tab.id ? 'slide-in-down' : ''} > {tab.icon} {tab.label} @@ -31,6 +32,7 @@ const styles = { background: 'var(--surface)', borderBottom: '1px solid var(--border)', flexShrink: 0, + backdropFilter: 'blur(8px)', }, tab: { flex: 1, @@ -43,14 +45,14 @@ const styles = { fontSize: 11, color: 'var(--muted)', borderRadius: 0, - borderBottom: '2px solid transparent', - transition: 'color 0.15s, border-color 0.15s', + borderBottom: '3px solid transparent', + transition: 'color 0.2s cubic-bezier(0.23, 1, 0.320, 1), border-color 0.2s cubic-bezier(0.23, 1, 0.320, 1)', minHeight: 48, }, active: { color: 'var(--accent)', - borderBottom: '2px solid var(--accent)', + borderBottom: '3px solid var(--accent)', }, - icon: { fontSize: 16 }, + icon: { fontSize: 18, transition: 'transform 0.2s' }, label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' }, } diff --git a/dashboard/src/components/layout/TopBar.jsx b/dashboard/src/components/layout/TopBar.jsx index c5a0cc8..aa2cbf7 100644 --- a/dashboard/src/components/layout/TopBar.jsx +++ b/dashboard/src/components/layout/TopBar.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { useNMEA } from '../../hooks/useNMEA.js' +import { useTheme } from '../../contexts/ThemeContext.jsx' const isMock = import.meta.env.VITE_USE_MOCK === 'true' const isDev = import.meta.env.DEV @@ -10,6 +11,7 @@ function formatTime() { export default function TopBar() { const { sog, heading, connected } = useNMEA() + const { isDark, toggleTheme } = useTheme() const [time, setTime] = useState(formatTime()) // Clock tick @@ -23,7 +25,7 @@ export default function TopBar() {
⚓ Bordanlage {isMock && DEV · MOCK DATA} - {isDev && !isMock && DEV · LIVE} + {isDev && !isMock && DEV · LIVE}
@@ -45,6 +47,14 @@ export default function TopBar() {
+ {time}
diff --git a/dashboard/src/contexts/ThemeContext.jsx b/dashboard/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..b290af5 --- /dev/null +++ b/dashboard/src/contexts/ThemeContext.jsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +const ThemeContext = createContext() + +export function ThemeProvider({ children }) { + const [isDark, setIsDark] = useState(() => { + const saved = localStorage.getItem('theme') + if (saved) return saved === 'dark' + return window.matchMedia('(prefers-color-scheme: dark)').matches + }) + + useEffect(() => { + const root = document.documentElement + if (isDark) { + root.classList.remove('light-mode') + } else { + root.classList.add('light-mode') + } + localStorage.setItem('theme', isDark ? 'dark' : 'light') + }, [isDark]) + + const toggleTheme = () => setIsDark(prev => !prev) + + return ( + + {children} + + ) +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) throw new Error('useTheme must be used within ThemeProvider') + return context +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 700c6a2..e2b2c99 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1,13 +1,16 @@ /* ─── CSS Custom Properties ───────────────────────────────────────────────── */ :root { + /* Dark Mode Colors */ --bg: #07111f; --surface: #0a1928; --surface2: #0d2035; --border: #1e2a3a; --text: #e2eaf2; --muted: #4a6080; - --accent: #38bdf8; - --success: #34d399; + + /* Brand Colors */ + --accent: #0ea5e9; + --success: #10b981; --warning: #f59e0b; --danger: #ef4444; --spotify: #1DB954; @@ -18,6 +21,34 @@ --radius: 8px; --radius-lg: 16px; + + /* Glassmorphism */ + --glass-bg: rgba(10, 25, 40, 0.7); + --glass-blur: 12px; +} + +/* Light Mode */ +@media (prefers-color-scheme: light) { + :root { + --bg: #f8fafc; + --surface: #f1f5f9; + --surface2: #e2e8f0; + --border: #cbd5e1; + --text: #0f172a; + --muted: #64748b; + --glass-bg: rgba(241, 245, 249, 0.8); + } +} + +/* Custom Light Mode Toggle */ +html.light-mode { + --bg: #f8fafc; + --surface: #f1f5f9; + --surface2: #e2e8f0; + --border: #cbd5e1; + --text: #0f172a; + --muted: #64748b; + --glass-bg: rgba(241, 245, 249, 0.8); } /* ─── Reset ───────────────────────────────────────────────────────────────── */ @@ -39,11 +70,76 @@ html, body, #root { ::-webkit-scrollbar-track { background: var(--surface); } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +/* ─── Animations ───────────────────────────────────────────────────────────── */ +@keyframes slideInUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideInDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes shimmer { + 0% { background-position: -1000px 0; } + 100% { background-position: 1000px 0; } +} + +/* ─── Glassmorphic Components ───────────────────────────────────────────────── */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-lg); +} + +.glass-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius); + transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); +} + +.glass-card:hover { + background: rgba(10, 25, 40, 0.85); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(14, 165, 233, 0.15); +} + +/* Light mode card hover */ +html.light-mode .glass-card:hover { + background: rgba(241, 245, 249, 0.95); + box-shadow: 0 8px 32px rgba(14, 165, 233, 0.1); +} + /* ─── Utilities ───────────────────────────────────────────────────────────── */ .mono { font-family: var(--font-mono); } .muted { color: var(--muted); } .accent { color: var(--accent); } +.fade-in { animation: fadeIn 0.3s ease-out; } +.slide-in-up { animation: slideInUp 0.3s cubic-bezier(0.23, 1, 0.320, 1); } +.slide-in-down { animation: slideInDown 0.3s cubic-bezier(0.23, 1, 0.320, 1); } +.pulse { animation: pulse 2s ease-in-out infinite; } + +/* ─── Button Variants ───────────────────────────────────────────────────────── */ + +/* ─── Button Variants ───────────────────────────────────────────────────────── */ button { cursor: pointer; background: none; @@ -56,9 +152,52 @@ button { align-items: center; justify-content: center; border-radius: var(--radius); - transition: background 0.15s, opacity 0.15s; + transition: all 0.2s cubic-bezier(0.23, 1, 0.320, 1); + font-weight: 500; +} + +button:hover { + transform: scale(1.05); +} + +button:active { + opacity: 0.8; + transform: scale(0.98); +} + +/* Primary Button */ +button.primary { + background: var(--accent); + color: white; + box-shadow: 0 4px 15px rgba(14, 165, 233, 0.3); +} + +button.primary:hover { + background: #06b6d4; + box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4); +} + +/* Ghost Button */ +button.ghost { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +button.ghost:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + +/* Icon Button */ +button.icon { + border-radius: 50%; + width: 40px; + height: 40px; +} + +button.icon:hover { + background: rgba(255, 255, 255, 0.1); } -button:active { opacity: 0.7; } input[type=range] { -webkit-appearance: none;