feat: complete dashboard redesign, proxy unification, and Windows compatibility fixes

This commit is contained in:
2026-04-02 12:13:37 +02:00
parent 8192388c5d
commit fec4e4635c
33 changed files with 3002 additions and 135 deletions

View File

@@ -7,6 +7,42 @@ server {
try_files $uri $uri/ /index.html;
}
# SignalK Proxy (incl. WebSocket)
location /signalk {
proxy_pass http://signalk:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Snapcast Proxy (incl. WebSocket)
location /snapcast-ws {
proxy_pass http://snapserver:1780;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Mopidy Proxy (incl. WebSocket)
location /mopidy {
proxy_pass http://mopidy:6680;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Jellyfin Proxy
location /jellyfin {
proxy_pass http://jellyfin:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;

View File

@@ -11,6 +11,8 @@ export default function NavigationMap() {
const shipMarkerRef = useRef(null)
const trackSourceRef = useRef(false)
const [zoom, setZoom] = useState(11)
const [mapError, setMapError] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const api = getApi()
const snapshot = api.signalk.getSnapshot?.()
@@ -22,7 +24,9 @@ export default function NavigationMap() {
useEffect(() => {
if (map.current) return
map.current = new maplibregl.Map({
try {
setIsLoading(true)
map.current = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
@@ -177,9 +181,28 @@ export default function NavigationMap() {
})
map.current.on('zoom', () => setZoom(map.current.getZoom()))
map.current.on('load', () => {
setIsLoading(false)
setMapError(null)
})
map.current.on('error', (e) => {
console.error('Map error:', e)
setMapError('Map failed to load. Check your internet connection.')
setIsLoading(false)
})
return () => {
// Cleanup is handled by React
if (map.current) {
map.current.remove()
map.current = null
}
}
} catch (error) {
console.error('Failed to initialize map:', error)
setMapError(`Map initialization failed: ${error.message}`)
setIsLoading(false)
}
}, [])
@@ -191,11 +214,14 @@ export default function NavigationMap() {
const trackSource = map.current.getSource('track')
if (trackSource && trackSourceRef.current) {
const currentData = trackSource._data
if (currentData.geometry.coordinates.length > 500) {
currentData.geometry.coordinates.shift() // Keep last 500 points
// Check if geometry exists and has coordinates array
if (currentData?.geometry?.coordinates) {
if (currentData.geometry.coordinates.length > 500) {
currentData.geometry.coordinates.shift() // Keep last 500 points
}
currentData.geometry.coordinates.push([lon, lat])
trackSource.setData(currentData)
}
currentData.geometry.coordinates.push([lon, lat])
trackSource.setData(currentData)
}
map.current.getSource('ship').setData({
@@ -214,8 +240,34 @@ export default function NavigationMap() {
}
}, [lat, lon, heading, zoom])
// Show error state
if (mapError) {
return (
<div style={styles.container}>
<div style={styles.errorState}>
<div style={styles.errorIcon}></div>
<div style={styles.errorTitle}>Karte konnte nicht geladen werden</div>
<div style={styles.errorMessage}>{mapError}</div>
<button
className="primary"
onClick={() => window.location.reload()}
style={{ marginTop: 16 }}
>
Neu laden
</button>
</div>
</div>
)
}
return (
<div style={styles.container}>
{isLoading && (
<div style={styles.loadingOverlay}>
<div style={styles.spinner}></div>
<div style={{ marginTop: 12, color: 'var(--muted)' }}>Karte wird geladen...</div>
</div>
)}
<div ref={mapContainer} style={styles.mapBox} />
{/* Map Controls */}
@@ -384,4 +436,51 @@ const styles = {
padding: '4px 8px',
zIndex: 500,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'var(--surface)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
},
spinner: {
width: 40,
height: 40,
border: '4px solid var(--border)',
borderTop: '4px solid var(--accent)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
errorState: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: 32,
textAlign: 'center',
background: 'var(--surface)',
borderRadius: 'var(--radius)',
},
errorIcon: {
fontSize: 48,
marginBottom: 16,
},
errorTitle: {
fontSize: 18,
fontWeight: 600,
color: 'var(--text)',
marginBottom: 8,
},
errorMessage: {
fontSize: 14,
color: 'var(--muted)',
maxWidth: 400,
},
}

View File

@@ -0,0 +1,204 @@
import { useNMEA } from '../../hooks/useNMEA.js'
// Simple fallback map component without MapLibre (no external dependencies)
export default function SimpleNavigationMap() {
const { lat, lon, heading, sog, cog } = useNMEA()
const mapLat = lat ?? 54.32
const mapLon = lon ?? 10.22
// Simple marker rotation based on heading
const rotation = heading ?? cog ?? 0
return (
<div style={styles.container}>
{/* Info Panel */}
<div style={styles.infoPanel}>
<div style={styles.infoRow}>
<span style={styles.label}>Position</span>
<span style={styles.value}>
{lat != null ? lat.toFixed(5) : '--'}° N
</span>
</div>
<div style={styles.infoRow}>
<span style={styles.label}></span>
<span style={styles.value}>
{lon != null ? lon.toFixed(5) : '--'}° E
</span>
</div>
{heading != null && (
<div style={styles.infoRow}>
<span style={styles.label}>Heading</span>
<span style={styles.value}>{Math.round(heading)}°</span>
</div>
)}
{sog != null && (
<div style={styles.infoRow}>
<span style={styles.label}>Speed</span>
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
</div>
)}
</div>
{/* Simple visual representation */}
<div style={styles.mapView}>
<div style={styles.compassRose}>
<div style={styles.northMarker}>N</div>
<div
style={{
...styles.shipMarker,
transform: `rotate(${rotation}deg)`
}}
>
</div>
</div>
<div style={styles.coords}>
{lat != null && lon != null ? (
<>
<div>{lat.toFixed(5)}°</div>
<div>{lon.toFixed(5)}°</div>
</>
) : (
<div style={{ color: 'var(--muted)' }}>No GPS signal</div>
)}
</div>
</div>
{/* Map notice */}
<div style={styles.notice}>
<div style={styles.noticeTitle}>🗺 Karten-Modus</div>
<div style={styles.noticeText}>
Interaktive Seekarte ist im Production Mode verfügbar.
<br />
Hier siehst du Position, Kurs und Geschwindigkeit.
</div>
<a
href="http://localhost:3000"
target="_blank"
rel="noreferrer"
style={styles.link}
>
SignalK Karte öffnen (Port 3000)
</a>
</div>
</div>
)
}
const styles = {
container: {
position: 'relative',
flex: 1,
background: 'var(--surface)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 400,
},
mapView: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 32,
padding: 32,
},
compassRose: {
position: 'relative',
width: 180,
height: 180,
border: '2px solid var(--accent)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'radial-gradient(circle, rgba(14, 165, 233, 0.05) 0%, transparent 70%)',
},
northMarker: {
position: 'absolute',
top: 8,
fontSize: 14,
fontWeight: 700,
color: 'var(--accent)',
},
shipMarker: {
fontSize: 48,
color: 'var(--accent)',
transition: 'transform 0.5s ease-out',
},
coords: {
fontFamily: 'var(--font-mono)',
fontSize: 16,
fontWeight: 600,
color: 'var(--text)',
textAlign: 'center',
lineHeight: 1.5,
},
infoPanel: {
background: 'var(--glass-bg)',
backdropFilter: 'blur(var(--glass-blur))',
WebkitBackdropFilter: 'blur(var(--glass-blur))',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 'var(--radius-lg)',
padding: '12px 16px',
margin: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
},
infoRow: {
display: 'flex',
justifyContent: 'space-between',
gap: 16,
},
label: {
color: 'var(--muted)',
fontWeight: 600,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
value: {
fontFamily: 'var(--font-mono)',
fontWeight: 600,
color: 'var(--accent)',
fontSize: 13,
},
notice: {
background: 'var(--surface2)',
padding: 20,
borderTop: '1px solid var(--border)',
textAlign: 'center',
},
noticeTitle: {
fontSize: 14,
fontWeight: 600,
color: 'var(--text)',
marginBottom: 8,
},
noticeText: {
fontSize: 12,
color: 'var(--muted)',
lineHeight: 1.5,
marginBottom: 12,
},
link: {
display: 'inline-block',
fontSize: 12,
color: 'var(--accent)',
textDecoration: 'none',
fontWeight: 600,
padding: '6px 12px',
borderRadius: 'var(--radius)',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid rgba(14, 165, 233, 0.3)',
transition: 'all 0.2s',
},
}

View File

@@ -0,0 +1,222 @@
import React from 'react';
import { useNMEA } from '../../hooks/useNMEA';
const CATEGORIES = [
{ id: 'lights', icon: '💡', label: 'Lights', pos: { top: '15%', left: '10%' } },
{ id: 'climate', icon: '❄️', label: 'Climate', pos: { top: '35%', left: '5%' } },
{ id: 'nav', icon: '📍', label: 'Navigation', pos: { top: '55%', left: '5%' } },
{ id: 'audio', icon: '🎵', label: 'Audio', pos: { top: '75%', left: '10%' } },
{ id: 'battery', icon: '🔋', label: 'Energy', pos: { bottom: '15%', left: '15%' } },
{ id: 'tanks', icon: '🌊', label: 'Tanks', pos: { top: '15%', right: '10%' } },
{ id: 'power', icon: '🔌', label: 'Shore Power', pos: { top: '35%', right: '5%' } },
{ id: 'wind', icon: '🌬️', label: 'Wind', pos: { top: '55%', right: '5%' } },
{ id: 'engine', icon: '⚙️', label: 'Engine', pos: { top: '75%', right: '10%' } },
];
export default function BoatControl({ activeCategory, onCategoryChange }) {
const { sog, heading } = useNMEA();
return (
<div style={styles.container}>
<div style={styles.shipControlLabel}>SHIP CONTROL</div>
<div style={styles.mainArea}>
{/* Category Icons Left */}
{CATEGORIES.slice(0, 5).map(cat => (
<button
key={cat.id}
style={{
...styles.catBtn,
...cat.pos,
...(activeCategory === cat.id ? styles.catBtnActive : {})
}}
onClick={() => onCategoryChange(cat.id)}
>
<span style={styles.catIcon}>{cat.icon}</span>
</button>
))}
{/* Central Boat Graphic */}
<div style={styles.boatContainer}>
<img
src="https://www.prestige-yachts.com/fichiers/Prestige_F4.9_Profile_600.png"
alt="Prestige Yacht"
style={styles.boatImg}
onError={(e) => {
// Fallback if image fails to load
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
<div style={{...styles.boatFallback, display: 'none'}}>
<div style={styles.boatHull}></div>
</div>
{/* Central Instrument Over Boat */}
<div style={styles.centralGauge}>
<div style={styles.gaugeValue}>{sog?.toFixed(1) || '0.0'}</div>
<div style={styles.gaugeUnit}>knots</div>
<div style={styles.gaugeHeading}>{Math.round(heading || 0)}°</div>
</div>
</div>
{/* Category Icons Right */}
{CATEGORIES.slice(5).map(cat => (
<button
key={cat.id}
style={{
...styles.catBtn,
...cat.pos,
...(activeCategory === cat.id ? styles.catBtnActive : {})
}}
onClick={() => onCategoryChange(cat.id)}
>
<span style={styles.catIcon}>{cat.icon}</span>
</button>
))}
</div>
<div style={styles.bottomBar}>
<div style={styles.bottomIcon}></div>
<div style={styles.time}>{new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</div>
<div style={styles.bottomIcon}>📖</div>
</div>
</div>
);
}
const styles = {
container: {
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
overflow: 'hidden',
},
shipControlLabel: {
position: 'absolute',
top: 40,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 48,
fontWeight: 300,
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.9)',
fontFamily: 'serif',
},
mainArea: {
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
catBtn: {
position: 'absolute',
width: 60,
height: 60,
borderRadius: '50%',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
zIndex: 10,
},
catBtnActive: {
background: 'rgba(14, 165, 233, 0.2)',
borderColor: '#0ea5e9',
boxShadow: '0 0 20px rgba(14, 165, 233, 0.4)',
transform: 'scale(1.1)',
},
catIcon: {
fontSize: 24,
opacity: 0.8,
},
boatContainer: {
position: 'relative',
width: '70%',
maxWidth: 800,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: 'fadeIn 1s ease-out',
},
boatImg: {
width: '100%',
height: 'auto',
filter: 'drop-shadow(0 20px 30px rgba(0,0,0,0.5)) brightness(1.1)',
},
boatFallback: {
width: 400,
height: 100,
alignItems: 'center',
justifyContent: 'center',
},
boatHull: {
width: '100%',
height: 40,
background: '#fff',
borderRadius: '50% 50% 10% 10%',
},
centralGauge: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 180,
height: 180,
borderRadius: '50%',
background: 'rgba(7, 17, 31, 0.6)',
backdropFilter: 'blur(10px)',
border: '2px solid rgba(255,255,255,0.15)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 40px rgba(0,0,0,0.5)',
},
gaugeValue: {
fontSize: 42,
fontWeight: 700,
fontFamily: 'var(--font-mono)',
color: '#fff',
},
gaugeUnit: {
fontSize: 12,
textTransform: 'uppercase',
color: 'var(--muted)',
letterSpacing: '0.1em',
marginTop: -4,
},
gaugeHeading: {
marginTop: 8,
fontSize: 18,
fontWeight: 600,
color: 'var(--accent)',
},
bottomBar: {
height: 60,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 40,
paddingBottom: 20,
},
bottomIcon: {
fontSize: 20,
opacity: 0.5,
cursor: 'pointer',
},
time: {
fontSize: 18,
fontWeight: 400,
color: 'rgba(255,255,255,0.8)',
fontFamily: 'var(--font-mono)',
}
};

View File

@@ -0,0 +1,125 @@
import React from 'react';
const ZONES = [
{ id: 'salon', label: 'Salon', top: '40%', left: '30%', width: '20%', height: '20%' },
{ id: 'cockpit', label: 'Cockpit', top: '40%', left: '55%', width: '15%', height: '20%' },
{ id: 'bug', label: 'Owner Cabin', top: '40%', left: '10%', width: '15%', height: '20%' },
{ id: 'heck', label: 'VIP Cabin', top: '40%', left: '75%', width: '15%', height: '20%' },
];
export default function FloorPlan({ type, onZoneClick }) {
return (
<div style={styles.container}>
<div style={styles.floorPlanWrapper}>
{/* Simple SVG/CSS representation of a boat deck */}
<div style={styles.deckOutline}>
{ZONES.map(zone => (
<div
key={zone.id}
style={{
...styles.zone,
top: zone.top,
left: zone.left,
width: zone.width,
height: zone.height,
}}
onClick={() => onZoneClick(zone.id)}
>
<div style={styles.zoneLabel}>{zone.label}</div>
<div style={styles.zoneStatus}>
{type === 'lights' ? '💡 80%' : '🌡️ 22°C'}
</div>
</div>
))}
</div>
</div>
<div style={styles.controls}>
<div style={styles.controlHeader}>{type === 'lights' ? 'Lighting Control' : 'Climate Control'}</div>
<div style={styles.controlRow}>
<span>Master Switch</span>
<button style={styles.toggle}>ON</button>
</div>
</div>
</div>
);
}
const styles = {
container: {
padding: 20,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 30,
animation: 'slideInUp 0.4s ease-out',
},
floorPlanWrapper: {
width: '100%',
maxWidth: 900,
height: 300,
background: 'rgba(255,255,255,0.02)',
borderRadius: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
border: '1px solid rgba(255,255,255,0.05)',
},
deckOutline: {
width: '90%',
height: '60%',
border: '2px solid rgba(255,255,255,0.2)',
borderRadius: '80px 80px 40px 40px',
position: 'relative',
},
zone: {
position: 'absolute',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
},
zoneLabel: {
fontSize: 10,
color: 'var(--muted)',
textTransform: 'uppercase',
},
zoneStatus: {
fontSize: 12,
fontWeight: 600,
},
controls: {
width: '100%',
maxWidth: 400,
background: 'var(--glass-bg)',
padding: 20,
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.1)',
},
controlHeader: {
fontSize: 14,
fontWeight: 700,
marginBottom: 15,
textTransform: 'uppercase',
color: 'var(--accent)',
},
controlRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
toggle: {
background: 'var(--accent)',
color: 'white',
padding: '4px 12px',
borderRadius: 4,
fontSize: 12,
fontWeight: 700,
}
};

View File

@@ -1,17 +1,20 @@
import { useState, useEffect } from 'react'
const SERVICES = [
{ id: 'signalk', name: 'SignalK', host: import.meta.env.VITE_SIGNALK_HOST || 'localhost', port: 3000, path: '/signalk' },
{ id: 'snapserver', name: 'Snapcast', host: import.meta.env.VITE_SNAPCAST_HOST || 'localhost', port: 1780, path: '/' },
{ id: 'mopidy', name: 'Mopidy', host: import.meta.env.VITE_MOPIDY_HOST || 'localhost', port: 6680, path: '/' },
{ id: 'jellyfin', name: 'Jellyfin', host: import.meta.env.VITE_JELLYFIN_HOST || 'localhost', port: 8096, path: '/' },
{ id: 'portainer', name: 'Portainer', host: import.meta.env.VITE_PORTAINER_HOST || 'localhost', port: 9000, path: '/' },
{ id: 'signalk', name: 'SignalK', path: '/signalk' },
{ id: 'snapserver', name: 'Snapcast', path: '/snapcast-ws' },
{ id: 'mopidy', name: 'Mopidy', path: '/mopidy' },
{ id: 'jellyfin', name: 'Jellyfin', path: '/jellyfin/' },
{ id: 'portainer', name: 'Portainer', path: '/portainer' },
{ id: 'spotify', name: 'Spotify', path: '/snapcast-ws' }, // Use snapcast as proxy for its status
{ id: 'airplay', name: 'AirPlay', path: '/snapcast-ws' },
]
async function ping(host, port, path) {
async function ping(path) {
try {
const host = window.location.host
// mode: 'no-cors' bypasses CORS blocks; any response (opaque) = server is up
await fetch(`http://${host}:${port}${path}`, {
await fetch(`http://${host}${path}`, {
method: 'GET',
mode: 'no-cors',
signal: AbortSignal.timeout(3000),
@@ -24,16 +27,26 @@ async function ping(host, port, path) {
export function useDocker() {
const [services, setServices] = useState(
SERVICES.map(s => ({ ...s, url: `http://${s.host}:${s.port}`, status: 'unknown' }))
SERVICES.map(s => ({ ...s, url: `http://${window.location.host}${s.path}`, status: 'unknown' }))
)
async function checkAll() {
const results = await Promise.all(
SERVICES.map(async s => ({
...s,
url: `http://${s.host}:${s.port}`,
status: await ping(s.host, s.port, s.path) ? 'online' : 'offline',
}))
SERVICES.map(async s => {
const url = `http://${window.location.host}${s.path}`
let status = 'offline'
if (s.id === 'spotify' || s.id === 'airplay') {
// These are special, they don't have their own UI
// We assume they are up if snapserver is up (for now)
// Ideally we check snapcast streams status
status = await ping('/snapcast-ws') ? 'online' : 'offline'
} else {
status = await ping(s.path) ? 'online' : 'offline'
}
return { ...s, url, status }
})
)
setServices(results)
}

View File

@@ -17,6 +17,7 @@ function parseStatus(status) {
export function useZones() {
const [zones, setZones] = useState([])
const [streams, setStreams] = useState([])
const [connected, setConnected] = useState(false)
const { snapcast } = getApi()
@@ -28,6 +29,7 @@ export function useZones() {
const status = await snapcast.call('Server.GetStatus')
if (alive) {
setZones(parseStatus(status))
setStreams(status?.server?.streams || [])
setConnected(true)
}
} catch {
@@ -36,7 +38,10 @@ export function useZones() {
}
const onUpdate = (msg) => {
if (msg?.result?.server) setZones(parseStatus(msg.result))
if (msg?.result?.server) {
setZones(parseStatus(msg.result))
setStreams(msg.result.server.streams || [])
}
}
snapcast.on('update', onUpdate)
@@ -65,5 +70,5 @@ export function useZones() {
if (zone) await setMuted(zoneId, !zone.muted)
}, [zones, setMuted])
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
return { zones, streams, connected, setVolume, setMuted, setSource, toggleZone }
}

View File

@@ -71,6 +71,10 @@ html, body, #root {
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ─── Animations ───────────────────────────────────────────────────────────── */
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }

View File

@@ -1,10 +1,11 @@
// API router uses real services by default; set VITE_USE_MOCK=true to force mocks.
// API router uses real services with automatic mock fallback if connection fails.
import { createSignalKMock } from './signalk.mock.js'
import { createSnapcastMock } from './snapcast.mock.js'
import { createMopidyMock } from './mopidy.mock.js'
import { createSignalKClient } from '../api/signalk.js'
import { createSnapcastClient } from '../api/snapcast.js'
import { createMopidyClient } from '../api/mopidy.js'
import { createJellyfinClient } from '../api/jellyfin.js'
const forceMock = import.meta.env.VITE_USE_MOCK === 'true'
@@ -14,18 +15,82 @@ export function createApi() {
signalk: createSignalKMock(),
snapcast: createSnapcastMock(),
mopidy: createMopidyMock(),
jellyfin: createJellyfinClient('http://localhost:8090/jellyfin', 'fake-key'),
isMock: true,
}
}
const snapcastHost = import.meta.env.VITE_SNAPCAST_HOST || 'localhost'
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
const mopidyHost = import.meta.env.VITE_MOPIDY_HOST || 'localhost'
// Real clients - use proxy-friendly URLs (going through port 8090)
const host = window.location.host
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const httpProtocol = window.location.protocol
// SignalK: baseUrl becomes ws://host/signalk (v1/stream appended in client)
const signalkReal = createSignalKClient(`${protocol}//${host}`)
// Snapcast: ws://host/snapcast-ws/jsonrpc
const snapcastUrl = `${protocol}//${host}/snapcast-ws/jsonrpc`
const snapcastReal = createSnapcastClient(snapcastUrl)
// Mopidy: ws://host/mopidy (ws appended in client)
const mopidyReal = createMopidyClient(`${protocol}//${host}`)
// Jellyfin: http://host/jellyfin
const jellyfinReal = createJellyfinClient(`${httpProtocol}//${host}/jellyfin`, 'YOUR_JELLYFIN_API_KEY')
const signalkMock = createSignalKMock()
// Proxy that switches between real and mock
const signalkProxy = {
listeners: {},
on: (event, handler) => {
if (!signalkProxy.listeners[event]) signalkProxy.listeners[event] = []
signalkProxy.listeners[event].push(handler)
signalkReal.on(event, handler)
signalkMock.on(event, handler)
},
off: (event, handler) => {
if (signalkProxy.listeners[event]) {
signalkProxy.listeners[event] = signalkProxy.listeners[event].filter(h => h !== handler)
}
signalkReal.off(event, handler)
signalkMock.off(event, handler)
},
emit: (event, data) => {
if (signalkProxy.listeners[event]) {
signalkProxy.listeners[event].forEach(h => h(data))
}
},
start: () => signalkMock.start(),
stop: () => signalkMock.stop(),
getSnapshot: () => signalkMock.getSnapshot(),
}
let usingSignalKMock = false
let signalkTimeout = setTimeout(() => {
// If no real data after 5 seconds, switch to mock
if (!usingSignalKMock) {
console.log('⚠️ SignalK not connected - using mock data (EYC Lingen → Ems route)')
usingSignalKMock = true
signalkMock.start()
signalkProxy.emit('connected', null)
}
}, 5000)
// If real connection succeeds, cancel mock
signalkReal.on('connected', () => {
clearTimeout(signalkTimeout)
if (usingSignalKMock) {
signalkMock.stop()
usingSignalKMock = false
}
})
return {
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
signalk: signalkProxy,
snapcast: snapcastReal,
mopidy: mopidyReal,
jellyfin: jellyfinReal,
isMock: false,
}
}

View File

@@ -1,20 +1,27 @@
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
// The ship navigates a realistic route around Bornholm Island, Baltic Sea.
// Simulates a SignalK WebSocket delta stream with realistic boat data.
// Route: Lokale Fahrt in Lingen über DEK und Ems (bleibt in der Region)
const INTERVAL_MS = 1000
function degToRad(d) { return d * Math.PI / 180 }
function radToDeg(r) { return r * 180 / Math.PI }
// Realistic sailing route around Bornholm Island, Baltic Sea
// Lokale Route: Lingen Bereich - DEK und Ems (bleibt in der Region)
const WAYPOINTS = [
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Start)' },
{ lat: 55.0500, lon: 13.5500, name: 'Bornholm North' },
{ lat: 55.1200, lon: 14.8000, name: 'Rønne Harbor' },
{ lat: 54.9500, lon: 15.2000, name: 'Bornholm East' },
{ lat: 54.5800, lon: 14.9000, name: 'Bornholm South' },
{ lat: 54.1500, lon: 13.2000, name: 'Gdansk Approach' },
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Loop)' },
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub Lingen' },
{ lat: 52.5280, lon: 7.3150, name: 'Kanal Ausfahrt' },
{ lat: 52.5400, lon: 7.3000, name: 'DEK Westlich' },
{ lat: 52.5500, lon: 7.2800, name: 'DEK Schleife West' },
{ lat: 52.5600, lon: 7.2700, name: 'DEK Nord' },
{ lat: 52.5700, lon: 7.2800, name: 'Brücke Nord' },
{ lat: 52.5750, lon: 7.3000, name: 'Ems Einfahrt' },
{ lat: 52.5800, lon: 7.3200, name: 'Ems Fluss Ost' },
{ lat: 52.5750, lon: 7.3400, name: 'Ems Kurve' },
{ lat: 52.5650, lon: 7.3500, name: 'Ems Süd' },
{ lat: 52.5500, lon: 7.3450, name: 'Ems Rückkehr' },
{ lat: 52.5400, lon: 7.3350, name: 'Kanal Rückkehr' },
{ lat: 52.5300, lon: 7.3250, name: 'Hafen Approach' },
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub (Ziel)' },
]
// Calculate distance between two coordinates in nautical miles

View File

@@ -1,42 +1,100 @@
import { useState } from 'react'
import { useNMEA } from '../hooks/useNMEA.js'
import Compass from '../components/instruments/Compass.jsx'
import SpeedLog from '../components/instruments/SpeedLog.jsx'
import DepthSounder from '../components/instruments/DepthSounder.jsx'
import WindRose from '../components/instruments/WindRose.jsx'
import NowPlaying from '../components/audio/NowPlaying.jsx'
import BoatControl from '../components/systems/BoatControl.jsx'
import FloorPlan from '../components/systems/FloorPlan.jsx'
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
import EngineData from '../components/systems/EngineData.jsx'
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
export default function Overview() {
const nmea = useNMEA()
const [activeCategory, setActiveCategory] = useState(null)
const handleBack = () => setActiveCategory(null)
return (
<div style={styles.page}>
{/* Instruments row */}
<section style={styles.instruments}>
<Compass heading={nmea.heading} cog={nmea.cog} />
<SpeedLog sog={nmea.sog} />
<DepthSounder depth={nmea.depth} />
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
</section>
{/* Now Playing */}
<NowPlaying compact />
{/* Zone quick overview */}
<section>
<div style={styles.sectionTitle}>Audio Zones</div>
<ZoneGrid />
</section>
{!activeCategory ? (
<BoatControl
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
) : (
<div style={styles.detailView}>
<div style={styles.detailHeader}>
<button onClick={handleBack} style={styles.backBtn}> Back</button>
<h2 style={styles.detailTitle}>{activeCategory.toUpperCase()}</h2>
</div>
<div style={styles.detailContent}>
{activeCategory === 'lights' && <FloorPlan type="lights" />}
{activeCategory === 'climate' && <FloorPlan type="climate" />}
{activeCategory === 'audio' && (
<div style={styles.audioWrapper}>
<ZoneGrid />
</div>
)}
{activeCategory === 'nav' && <InstrumentPanel />}
{activeCategory === 'engine' && <EngineData />}
{activeCategory === 'battery' && <BatteryStatus />}
</div>
</div>
)}
</div>
)
}
const styles = {
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'auto', flex: 1 },
instruments: {
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
padding: 16, background: 'var(--surface)',
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
page: {
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#07111f',
overflow: 'hidden',
},
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
detailView: {
flex: 1,
display: 'flex',
flexDirection: 'column',
animation: 'fadeIn 0.4s ease-out',
padding: 20,
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
},
detailHeader: {
display: 'flex',
alignItems: 'center',
gap: 20,
marginBottom: 20,
},
backBtn: {
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'var(--text)',
padding: '8px 16px',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
minHeight: 0,
minWidth: 0,
},
detailTitle: {
fontSize: 20,
fontWeight: 300,
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.8)',
margin: 0,
},
detailContent: {
flex: 1,
overflowY: 'auto',
padding: '0 10px',
},
audioWrapper: {
height: '100%',
display: 'flex',
flexDirection: 'column',
}
}