Move project from bordanlage/ to repo root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:31:08 +01:00
parent 946c0a5377
commit 77123a0df5
56 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from 'react'
import { getApi } from '../../mock/index.js'
export default function LibraryBrowser() {
const [tracks, setTracks] = useState([])
const [loading, setLoading] = useState(true)
const { mopidy } = getApi()
useEffect(() => {
mopidy.call('tracklist.get_tracks')
.then(t => { setTracks(t || []); setLoading(false) })
.catch(() => setLoading(false))
}, [])
async function playTrack(uri) {
await mopidy.call('tracklist.clear')
await mopidy.call('tracklist.add', { uris: [uri] })
await mopidy.call('playback.play')
}
if (loading) return <div style={{ color: 'var(--muted)', padding: 16 }}>Loading library</div>
if (!tracks.length) return <div style={{ color: 'var(--muted)', padding: 16 }}>No tracks found. Add music to ./music</div>
return (
<div style={styles.list}>
{tracks.map((track, i) => (
<button key={track.uri || i} style={styles.row} onClick={() => playTrack(track.uri)}>
<span style={styles.num}>{i + 1}</span>
<div style={styles.meta}>
<span style={styles.name}>{track.name}</span>
<span style={styles.artist}>{track.artists?.map(a => a.name).join(', ')}</span>
</div>
<span style={styles.album}>{track.album?.name}</span>
</button>
))}
</div>
)
}
const styles = {
list: { display: 'flex', flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: 400 },
row: {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 'var(--radius)',
background: 'none', border: '1px solid transparent',
color: 'var(--text)', textAlign: 'left', minHeight: 48,
},
num: { fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', minWidth: 20 },
meta: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
name: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
artist: { fontSize: 11, color: 'var(--muted)' },
album: { fontSize: 11, color: 'var(--muted)', minWidth: 100, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}

View File

@@ -0,0 +1,90 @@
import { usePlayer } from '../../hooks/usePlayer.js'
function formatTime(ms) {
if (!ms) return '0:00'
const s = Math.floor(ms / 1000)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
export default function NowPlaying({ compact = false }) {
const { currentTrack, state, position, play, pause, next, previous, connected } = usePlayer()
if (!connected) {
return (
<div style={styles.container}>
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Audio not connected</span>
</div>
)
}
const progress = currentTrack?.duration
? Math.min(100, (position / currentTrack.duration) * 100)
: 0
return (
<div style={{ ...styles.container, ...(compact ? styles.compact : {}) }}>
{/* Cover placeholder */}
<div style={styles.cover}>
{currentTrack?.coverUrl
? <img src={currentTrack.coverUrl} alt="cover" style={styles.coverImg} />
: <span style={styles.coverIcon}></span>}
</div>
<div style={styles.info}>
<div style={styles.title}>{currentTrack?.title || 'Nothing playing'}</div>
<div style={styles.artist}>{currentTrack?.artist || ''}</div>
{!compact && <div style={styles.album}>{currentTrack?.album || ''}</div>}
{/* Progress bar */}
{currentTrack && (
<div style={styles.progressRow}>
<span style={styles.timeText}>{formatTime(position)}</span>
<div style={styles.progressBg}>
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
</div>
<span style={styles.timeText}>{formatTime(currentTrack.duration)}</span>
</div>
)}
{/* Controls */}
<div style={styles.controls}>
<button style={styles.btn} onClick={previous}></button>
<button style={{ ...styles.btn, ...styles.playBtn }}
onClick={state === 'playing' ? pause : play}>
{state === 'playing' ? '⏸' : '▶'}
</button>
<button style={styles.btn} onClick={next}></button>
</div>
</div>
</div>
)
}
const styles = {
container: {
display: 'flex', gap: 16, padding: 16,
background: 'var(--surface)', borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
alignItems: 'center',
},
compact: { padding: '10px 14px' },
cover: {
width: 64, height: 64, flexShrink: 0,
background: 'var(--surface2)', borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
},
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
coverIcon: { fontSize: 28, color: 'var(--muted)' },
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
artist: { fontSize: 12, color: 'var(--muted)' },
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
controls: { display: 'flex', gap: 4, marginTop: 4 },
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react'
import { getApi } from '../../mock/index.js'
const BUILT_IN_STATIONS = [
{ name: 'SWR3', uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3' },
{ name: 'NDR 1 Welle Nord', uri: 'http://ndr.de/ndr1welle-nord-128.mp3' },
{ name: 'Deutschlandfunk', uri: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3' },
{ name: 'KISS FM', uri: 'http://topstream.kissfm.de/kissfm' },
]
export default function RadioBrowser() {
const [playing, setPlaying] = useState(null)
const { mopidy } = getApi()
async function playStation(uri, name) {
try {
await mopidy.call('tracklist.clear')
await mopidy.call('tracklist.add', { uris: [uri] })
await mopidy.call('playback.play')
setPlaying(uri)
} catch (e) {
console.error('Radio play error:', e)
}
}
return (
<div style={styles.container}>
<div style={styles.title}>Radio Stations</div>
{BUILT_IN_STATIONS.map(s => (
<button
key={s.uri}
style={{
...styles.station,
...(playing === s.uri ? styles.active : {}),
}}
onClick={() => playStation(s.uri, s.name)}
>
<span style={styles.dot}></span>
<span style={styles.stationName}>{s.name}</span>
{playing === s.uri && <span style={styles.live}>LIVE</span>}
</button>
))}
</div>
)
}
const styles = {
container: { display: 'flex', flexDirection: 'column', gap: 6 },
title: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
station: {
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px', background: 'var(--surface)',
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
color: 'var(--text)', textAlign: 'left', minHeight: 48,
transition: 'border-color 0.15s',
},
active: { borderColor: 'var(--accent)', background: 'var(--surface2)' },
dot: { color: 'var(--muted)', fontSize: 10 },
stationName: { flex: 1, fontSize: 14 },
live: { fontSize: 9, padding: '2px 5px', background: '#ef444422', color: 'var(--danger)', borderRadius: 3, fontWeight: 700 },
}

View File

@@ -0,0 +1,37 @@
const SOURCES = [
{ id: 'Spotify', label: 'Spotify', color: 'var(--spotify)', icon: '🎵' },
{ id: 'AirPlay', label: 'AirPlay', color: 'var(--airplay)', icon: '📡' },
{ id: 'Mopidy', label: 'Mopidy', color: 'var(--accent)', icon: '📻' },
]
export default function SourcePicker({ activeSource, onSelect }) {
return (
<div style={styles.row}>
{SOURCES.map(s => (
<button
key={s.id}
style={{
...styles.btn,
...(activeSource === s.id ? { background: s.color + '22', borderColor: s.color, color: s.color } : {}),
}}
onClick={() => onSelect(s.id)}
>
<span>{s.icon}</span>
<span style={{ fontSize: 12 }}>{s.label}</span>
</button>
))}
</div>
)
}
const styles = {
row: { display: 'flex', gap: 8 },
btn: {
flex: 1, height: 52, flexDirection: 'column',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', color: 'var(--muted)',
transition: 'all 0.15s',
minHeight: 52,
},
}

View File

@@ -0,0 +1,53 @@
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
const { id, name, active, volume, muted, source } = zone
return (
<div style={{
...styles.card,
opacity: active ? 1 : 0.45,
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
}}>
<div style={styles.header}>
<span style={styles.name}>{name}</span>
<div style={styles.badges}>
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
{active ? 'ON' : 'OFF'}
</span>
</div>
</div>
<div style={styles.source}>{source}</div>
<div style={styles.volumeRow}>
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
{muted ? '🔇' : '🔊'}
</button>
<input
type="range" min={0} max={100} value={muted ? 0 : volume}
onChange={e => onVolume(id, Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={styles.volVal}>{muted ? '' : volume}</span>
</div>
</div>
)
}
const styles = {
card: {
padding: 14,
background: 'var(--surface)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', gap: 10,
transition: 'border-color 0.2s, opacity 0.2s',
},
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
name: { fontWeight: 600, fontSize: 14 },
badges: { display: 'flex', gap: 4 },
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
source: { fontSize: 11, color: 'var(--muted)' },
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
}

View File

@@ -0,0 +1,32 @@
import { useZones } from '../../hooks/useZones.js'
import ZoneCard from './ZoneCard.jsx'
export default function ZoneGrid() {
const { zones, setVolume, setMuted, setSource } = useZones()
if (!zones.length) {
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones</div>
}
return (
<div style={styles.grid}>
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
onVolume={setVolume}
onMute={setMuted}
onSource={setSource}
/>
))}
</div>
)
}
const styles = {
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 12,
}
}

View File

@@ -0,0 +1,94 @@
// Animated SVG compass rose.
const CX = 96, CY = 96, R = 80
const CARDINALS = [
{ label: 'N', angle: 0 },
{ label: 'E', angle: 90 },
{ label: 'S', angle: 180 },
{ label: 'W', angle: 270 },
]
function polarXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
export default function Compass({ heading = 0, cog }) {
const hdg = heading ?? 0
const hasCog = cog != null
return (
<svg width={192} height={192} viewBox="0 0 192 192">
{/* Outer ring */}
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={2} />
<circle cx={CX} cy={CY} r={R - 12} fill="none" stroke="var(--border)" strokeWidth={1} />
{/* Rotating rose */}
<g style={{
transformOrigin: `${CX}px ${CY}px`,
transform: `rotate(${-hdg}deg)`,
transition: 'transform 0.8s ease',
}}>
{/* 36 tick marks */}
{Array.from({ length: 36 }, (_, i) => {
const angle = i * 10
const outer = polarXY(CX, CY, R, angle)
const inner = polarXY(CX, CY, R - (i % 3 === 0 ? 10 : 5), angle)
return (
<line key={i}
x1={outer.x} y1={outer.y}
x2={inner.x} y2={inner.y}
stroke={i % 9 === 0 ? 'var(--accent)' : 'var(--muted)'}
strokeWidth={i % 9 === 0 ? 2 : 1}
/>
)
})}
{/* Cardinal labels */}
{CARDINALS.map(c => {
const p = polarXY(CX, CY, R - 22, c.angle)
return (
<text key={c.label}
x={p.x} y={p.y + 4}
textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={12} fontWeight={700}
fill={c.label === 'N' ? 'var(--danger)' : 'var(--text)'}
>
{c.label}
</text>
)
})}
</g>
{/* Fixed lubber line (ship's bow = top) */}
<line x1={CX} y1={CY - R + 2} x2={CX} y2={CY - R + 16}
stroke="var(--accent)" strokeWidth={3} strokeLinecap="round" />
{/* COG indicator */}
{hasCog && (() => {
const cogAngle = cog - hdg
const tip = polarXY(CX, CY, R - 6, cogAngle)
return (
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
stroke="var(--warning)" strokeWidth={2} strokeDasharray="4,3"
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.8s ease' }}
/>
)
})()}
{/* Center */}
<circle cx={CX} cy={CY} r={4} fill="var(--accent)" />
{/* Heading value */}
<text x={CX} y={CY + 26} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
{Math.round(hdg).toString().padStart(3, '0')}°
</text>
<text x={CX} y={186} textAnchor="middle"
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
HEADING
</text>
</svg>
)
}

View File

@@ -0,0 +1,16 @@
// Depth sounder with alarm threshold.
import Gauge from './Gauge.jsx'
export default function DepthSounder({ depth }) {
return (
<Gauge
value={depth}
min={0}
max={30}
label="Depth"
unit="m"
warning={3}
danger={1.5}
/>
)
}

View File

@@ -0,0 +1,118 @@
// Analog round gauge with animated needle.
const R = 80
const CX = 96
const CY = 96
const START_ANGLE = -225
const SWEEP = 270
function polarToXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
function arcPath(cx, cy, r, startDeg, endDeg) {
const start = polarToXY(cx, cy, r, startDeg)
const end = polarToXY(cx, cy, r, endDeg)
const large = endDeg - startDeg > 180 ? 1 : 0
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`
}
function buildTicks(startDeg, sweep, count) {
return Array.from({ length: count + 1 }, (_, i) => {
const angle = startDeg + (sweep / count) * i
const outer = polarToXY(CX, CY, R, angle)
const inner = polarToXY(CX, CY, R - (i % 2 === 0 ? 10 : 6), angle)
return { outer, inner, major: i % 2 === 0 }
})
}
export default function Gauge({ value, min = 0, max = 10, label = '', unit = '', danger, warning }) {
const clampedVal = Math.min(max, Math.max(min, value ?? min))
const ratio = (clampedVal - min) / (max - min)
const needleAngle = START_ANGLE + ratio * SWEEP
const warnRatio = warning != null ? (warning - min) / (max - min) : null
const dangRatio = danger != null ? (danger - min) / (max - min) : null
const ticks = buildTicks(START_ANGLE, SWEEP, 10)
const needleTip = polarToXY(CX, CY, R - 12, needleAngle)
const needleBase1 = polarToXY(CX, CY, 6, needleAngle + 90)
const needleBase2 = polarToXY(CX, CY, 6, needleAngle - 90)
const isWarning = warning != null && clampedVal >= warning
const isDanger = danger != null && clampedVal >= danger
const needleColor = isDanger ? 'var(--danger)' : isWarning ? 'var(--warning)' : 'var(--accent)'
return (
<svg width={192} height={192} viewBox="0 0 192 192" style={{ overflow: 'visible' }}>
{/* Background arc */}
<path
d={arcPath(CX, CY, R, START_ANGLE, START_ANGLE + SWEEP)}
fill="none" stroke="var(--border)" strokeWidth={8} strokeLinecap="round"
/>
{/* Warning zone */}
{warnRatio != null && (
<path
d={arcPath(CX, CY, R,
START_ANGLE + warnRatio * SWEEP,
START_ANGLE + (dangRatio ?? 1) * SWEEP)}
fill="none" stroke="#f59e0b33" strokeWidth={8}
/>
)}
{/* Danger zone */}
{dangRatio != null && (
<path
d={arcPath(CX, CY, R, START_ANGLE + dangRatio * SWEEP, START_ANGLE + SWEEP)}
fill="none" stroke="#ef444433" strokeWidth={8}
/>
)}
{/* Progress arc */}
<path
d={arcPath(CX, CY, R, START_ANGLE, needleAngle)}
fill="none" stroke={needleColor} strokeWidth={4} strokeLinecap="round"
style={{ transition: 'all 0.6s ease' }}
/>
{/* Ticks */}
{ticks.map((t, i) => (
<line key={i}
x1={t.outer.x} y1={t.outer.y}
x2={t.inner.x} y2={t.inner.y}
stroke={t.major ? 'var(--muted)' : 'var(--border)'}
strokeWidth={t.major ? 1.5 : 1}
/>
))}
{/* Needle */}
<polygon
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
fill={needleColor}
style={{ transition: 'all 0.6s ease', transformOrigin: `${CX}px ${CY}px` }}
/>
{/* Center cap */}
<circle cx={CX} cy={CY} r={6} fill="var(--surface2)" stroke={needleColor} strokeWidth={2} />
{/* Value text */}
<text x={CX} y={CY + 28} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)"
style={{ transition: 'all 0.3s' }}>
{value != null ? (Number.isInteger(max - min) && max - min <= 20
? Math.round(clampedVal)
: clampedVal.toFixed(1)) : '--'}
</text>
<text x={CX} y={CY + 42} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
{unit}
</text>
{/* Label */}
<text x={CX} y={186} textAnchor="middle" fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
{label.toUpperCase()}
</text>
</svg>
)
}

View File

@@ -0,0 +1,14 @@
// Speed through water / SOG gauge.
import Gauge from './Gauge.jsx'
export default function SpeedLog({ sog }) {
return (
<Gauge
value={sog}
min={0}
max={12}
label="Speed"
unit="kn"
/>
)
}

View File

@@ -0,0 +1,76 @@
// Wind rose showing apparent wind angle and speed.
const CX = 96, CY = 96, R = 70
function polarXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
export default function WindRose({ windAngle = 0, windSpeed = 0 }) {
const angle = windAngle ?? 0
const speed = windSpeed ?? 0
const tipLen = Math.min(R - 10, 20 + speed * 2.5)
const tip = polarXY(CX, CY, tipLen, angle)
const left = polarXY(CX, CY, 10, angle + 120)
const right = polarXY(CX, CY, 10, angle - 120)
const color = speed > 18 ? 'var(--danger)' : speed > 12 ? 'var(--warning)' : 'var(--accent)'
return (
<svg width={192} height={192} viewBox="0 0 192 192">
{/* Background rings */}
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={1} />
<circle cx={CX} cy={CY} r={R * 0.6} fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray="3,4" />
{/* Dividers every 45° */}
{Array.from({ length: 8 }, (_, i) => {
const p = polarXY(CX, CY, R, i * 45)
return <line key={i} x1={CX} y1={CY} x2={p.x} y2={p.y}
stroke="var(--border)" strokeWidth={1} />
})}
{/* Wind arrow */}
<g style={{ transition: 'all 0.6s ease' }}>
<polygon
points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`}
fill={color} opacity={0.85}
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
/>
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
stroke={color} strokeWidth={2}
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
/>
</g>
<circle cx={CX} cy={CY} r={5} fill="var(--surface2)" stroke={color} strokeWidth={2} />
{/* Speed value */}
<text x={CX} y={CY + 26} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
{speed.toFixed(1)}
</text>
<text x={CX} y={CY + 40} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
kn
</text>
{/* Labels */}
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => {
const p = polarXY(CX, CY, R + 12, a)
return (
<text key={l} x={p.x} y={p.y + 4} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
{l}
</text>
)
})}
<text x={CX} y={186} textAnchor="middle"
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
WIND
</text>
</svg>
)
}

View File

@@ -0,0 +1,56 @@
const TABS = [
{ id: 'overview', label: 'Overview', icon: '◈' },
{ id: 'navigation', label: 'Navigation', icon: '⊕' },
{ id: 'audio', label: 'Audio', icon: '♫' },
{ id: 'systems', label: 'Systems', icon: '⚙' },
]
export default function TabNav({ activeTab, onTabChange }) {
return (
<nav style={styles.nav}>
{TABS.map(tab => (
<button
key={tab.id}
style={{
...styles.tab,
...(activeTab === tab.id ? styles.active : {}),
}}
onClick={() => onTabChange(tab.id)}
>
<span style={styles.icon}>{tab.icon}</span>
<span style={styles.label}>{tab.label}</span>
</button>
))}
</nav>
)
}
const styles = {
nav: {
display: 'flex',
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
},
tab: {
flex: 1,
height: 48,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
fontSize: 11,
color: 'var(--muted)',
borderRadius: 0,
borderBottom: '2px solid transparent',
transition: 'color 0.15s, border-color 0.15s',
minHeight: 48,
},
active: {
color: 'var(--accent)',
borderBottom: '2px solid var(--accent)',
},
icon: { fontSize: 16 },
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useNMEA } from '../../hooks/useNMEA.js'
const isDev = import.meta.env.DEV
function formatTime() {
return new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
export default function TopBar() {
const { sog, heading, connected } = useNMEA()
const [time, setTime] = useState(formatTime())
// Clock tick
useState(() => {
const t = setInterval(() => setTime(formatTime()), 5000)
return () => clearInterval(t)
})
return (
<header style={styles.bar}>
<div style={styles.left}>
<span style={styles.logo}> Bordanlage</span>
{isDev && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
</div>
<div style={styles.center}>
{connected && sog != null && (
<span style={styles.stat}>
<span style={styles.val}>{sog.toFixed(1)}</span>
<span style={styles.unit}>kn</span>
</span>
)}
{connected && heading != null && (
<span style={styles.stat}>
<span style={styles.val}>{Math.round(heading)}°</span>
<span style={styles.unit}>HDG</span>
</span>
)}
{!connected && (
<span style={{ color: 'var(--muted)', fontSize: 13 }}>No signal</span>
)}
</div>
<div style={styles.right}>
<span style={styles.time}>{time}</span>
</div>
</header>
)
}
const styles = {
bar: {
height: 52,
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: 16,
flexShrink: 0,
},
left: { display: 'flex', alignItems: 'center', gap: 10, flex: 1 },
center: { display: 'flex', gap: 20, alignItems: 'center' },
right: { flex: 1, display: 'flex', justifyContent: 'flex-end' },
logo: { fontWeight: 700, fontSize: 15, color: 'var(--accent)', letterSpacing: '0.04em' },
devBadge: {
fontSize: 10, fontWeight: 600, padding: '2px 7px',
background: '#f59e0b22', color: 'var(--warning)',
border: '1px solid var(--warning)', borderRadius: 4,
letterSpacing: '0.06em',
},
stat: { display: 'flex', alignItems: 'baseline', gap: 3 },
val: { fontFamily: 'var(--font-mono)', fontSize: 16, color: 'var(--text)' },
unit: { fontSize: 10, color: 'var(--muted)' },
time: { fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--muted)' },
}

View File

@@ -0,0 +1,37 @@
import { useNMEA } from '../../hooks/useNMEA.js'
export default function ChartPlaceholder() {
const { lat, lon } = useNMEA()
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
// SignalK has a built-in chart viewer
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
return (
<div style={styles.container}>
<iframe
src={chartUrl}
style={styles.iframe}
title="Chart"
onError={() => {}}
/>
{lat != null && (
<div style={styles.coords}>
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
</div>
)}
</div>
)
}
const styles = {
container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)' },
iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface2)' },
coords: {
position: 'absolute', bottom: 12, left: 12,
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
display: 'flex', gap: 12,
},
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
}

View File

@@ -0,0 +1,67 @@
import { useNMEA } from '../../hooks/useNMEA.js'
import Gauge from '../instruments/Gauge.jsx'
import Compass from '../instruments/Compass.jsx'
import WindRose from '../instruments/WindRose.jsx'
function DataRow({ label, value, unit }) {
return (
<div style={styles.row}>
<span style={styles.label}>{label}</span>
<span style={styles.value}>
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
{unit && <span style={styles.unit}> {unit}</span>}
</span>
</div>
)
}
export default function InstrumentPanel() {
const nmea = useNMEA()
return (
<div style={styles.panel}>
{/* Gauges row */}
<div style={styles.gauges}>
<Compass heading={nmea.heading} cog={nmea.cog} />
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
<Gauge value={nmea.depth} min={0} max={30} label="Depth" unit="m" warning={3} danger={1.5} />
<Gauge value={nmea.sog} min={0} max={12} label="SOG" unit="kn" />
</div>
{/* Data table */}
<div style={styles.table}>
<DataRow label="COG" value={nmea.cog != null ? Math.round(nmea.cog) : null} unit="°" />
<DataRow label="Heading" value={nmea.heading != null ? Math.round(nmea.heading) : null} unit="°" />
<DataRow label="SOG" value={nmea.sog} unit="kn" />
<DataRow label="Depth" value={nmea.depth} unit="m" />
<DataRow label="Wind Speed" value={nmea.windSpeed} unit="kn" />
<DataRow label="Wind Angle" value={nmea.windAngle} unit="°" />
<DataRow label="Water Temp" value={nmea.waterTemp} unit="°C" />
<DataRow label="Air Temp" value={nmea.airTemp} unit="°C" />
<DataRow label="RPM" value={nmea.rpm} unit="" />
<DataRow label="Rudder" value={nmea.rudder} unit="°" />
<DataRow label="Fuel" value={nmea.fuel} unit="%" />
<DataRow label="Lat" value={nmea.lat?.toFixed(5)} unit="°N" />
<DataRow label="Lon" value={nmea.lon?.toFixed(5)} unit="°E" />
</div>
</div>
)
}
const styles = {
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
table: {
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: '0 24px', background: 'var(--surface)',
borderRadius: 'var(--radius)', padding: 16,
border: '1px solid var(--border)',
},
row: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 0', borderBottom: '1px solid var(--border)',
},
label: { fontSize: 12, color: 'var(--muted)' },
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
unit: { color: 'var(--muted)', fontSize: 11 },
}

View File

@@ -0,0 +1,35 @@
function BatteryBar({ label, voltage, nominal, low, critical }) {
const percent = Math.min(100, Math.max(0, ((voltage - critical) / (nominal - critical)) * 100))
const color = voltage < critical ? 'var(--danger)' : voltage < low ? 'var(--warning)' : 'var(--success)'
return (
<div style={styles.battery}>
<div style={styles.header}>
<span style={styles.label}>{label}</span>
<span style={{ ...styles.voltage, color }}>{voltage != null ? `${voltage.toFixed(2)} V` : '—'}</span>
</div>
<div style={styles.barBg}>
<div style={{ ...styles.barFill, width: `${percent}%`, background: color, transition: 'width 0.6s, background 0.3s' }} />
</div>
</div>
)
}
export default function BatteryStatus({ battery1, battery2 }) {
return (
<div style={styles.container}>
<BatteryBar label="Starter (12V)" voltage={battery1} nominal={12.8} low={12.2} critical={11.8} />
<BatteryBar label="House (24V)" voltage={battery2} nominal={25.6} low={24.8} critical={23.5} />
</div>
)
}
const styles = {
container: { display: 'flex', flexDirection: 'column', gap: 14, padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' },
battery: { display: 'flex', flexDirection: 'column', gap: 6 },
header: { display: 'flex', justifyContent: 'space-between' },
label: { fontSize: 12, color: 'var(--muted)' },
voltage: { fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600 },
barBg: { height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' },
barFill: { height: '100%', borderRadius: 4 },
}

View File

@@ -0,0 +1,22 @@
import Gauge from '../instruments/Gauge.jsx'
import { useNMEA } from '../../hooks/useNMEA.js'
export default function EngineData() {
const { rpm, waterTemp, fuel } = useNMEA()
return (
<div style={styles.grid}>
<Gauge value={rpm} min={0} max={3000} label="RPM" unit="rpm" warning={2500} danger={2800} />
<Gauge value={waterTemp} min={0} max={120} label="Coolant" unit="°C" warning={85} danger={100} />
<Gauge value={fuel} min={0} max={100} label="Fuel" unit="%" warning={20} danger={10} />
</div>
)
}
const styles = {
grid: {
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
padding: 16, background: 'var(--surface)',
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
}
}

View File

@@ -0,0 +1,40 @@
import { useDocker } from '../../hooks/useDocker.js'
const STATUS_COLOR = {
online: 'var(--success)',
offline: 'var(--danger)',
unknown: 'var(--muted)',
}
export default function ServiceHealth() {
const { services, refresh } = useDocker()
return (
<div style={styles.container}>
<div style={styles.header}>
<span style={styles.title}>Services</span>
<button style={styles.refreshBtn} onClick={refresh}> Refresh</button>
</div>
{services.map(s => (
<div key={s.id} style={styles.row}>
<span style={{ ...styles.dot, color: STATUS_COLOR[s.status] }}></span>
<span style={styles.name}>{s.name}</span>
<a href={s.url} target="_blank" rel="noreferrer" style={styles.link}>{s.url.replace(/^https?:\/\//, '')}</a>
<span style={{ ...styles.status, color: STATUS_COLOR[s.status] }}>{s.status}</span>
</div>
))}
</div>
)
}
const styles = {
container: { padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 10 },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
title: { fontWeight: 600, fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)' },
refreshBtn: { fontSize: 12, color: 'var(--accent)', minWidth: 0, minHeight: 0, padding: '4px 8px', height: 'auto' },
row: { display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border)' },
dot: { fontSize: 10 },
name: { minWidth: 90, fontWeight: 500, fontSize: 13 },
link: { flex: 1, fontSize: 11, color: 'var(--muted)', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
status: { fontSize: 11, fontWeight: 600, textTransform: 'uppercase' },
}