Move project from bordanlage/ to repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
53
dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
53
dashboard/src/components/audio/LibraryBrowser.jsx
Normal 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' },
|
||||
}
|
||||
90
dashboard/src/components/audio/NowPlaying.jsx
Normal file
90
dashboard/src/components/audio/NowPlaying.jsx
Normal 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 },
|
||||
}
|
||||
61
dashboard/src/components/audio/RadioBrowser.jsx
Normal file
61
dashboard/src/components/audio/RadioBrowser.jsx
Normal 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 },
|
||||
}
|
||||
37
dashboard/src/components/audio/SourcePicker.jsx
Normal file
37
dashboard/src/components/audio/SourcePicker.jsx
Normal 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,
|
||||
},
|
||||
}
|
||||
53
dashboard/src/components/audio/ZoneCard.jsx
Normal file
53
dashboard/src/components/audio/ZoneCard.jsx
Normal 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)' },
|
||||
}
|
||||
32
dashboard/src/components/audio/ZoneGrid.jsx
Normal file
32
dashboard/src/components/audio/ZoneGrid.jsx
Normal 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,
|
||||
}
|
||||
}
|
||||
94
dashboard/src/components/instruments/Compass.jsx
Normal file
94
dashboard/src/components/instruments/Compass.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
dashboard/src/components/instruments/DepthSounder.jsx
Normal file
16
dashboard/src/components/instruments/DepthSounder.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
118
dashboard/src/components/instruments/Gauge.jsx
Normal file
118
dashboard/src/components/instruments/Gauge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
dashboard/src/components/instruments/SpeedLog.jsx
Normal file
14
dashboard/src/components/instruments/SpeedLog.jsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
76
dashboard/src/components/instruments/WindRose.jsx
Normal file
76
dashboard/src/components/instruments/WindRose.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
dashboard/src/components/layout/TabNav.jsx
Normal file
56
dashboard/src/components/layout/TabNav.jsx
Normal 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' },
|
||||
}
|
||||
77
dashboard/src/components/layout/TopBar.jsx
Normal file
77
dashboard/src/components/layout/TopBar.jsx
Normal 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)' },
|
||||
}
|
||||
37
dashboard/src/components/nav/ChartPlaceholder.jsx
Normal file
37
dashboard/src/components/nav/ChartPlaceholder.jsx
Normal 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)' },
|
||||
}
|
||||
67
dashboard/src/components/nav/InstrumentPanel.jsx
Normal file
67
dashboard/src/components/nav/InstrumentPanel.jsx
Normal 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 },
|
||||
}
|
||||
35
dashboard/src/components/systems/BatteryStatus.jsx
Normal file
35
dashboard/src/components/systems/BatteryStatus.jsx
Normal 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 },
|
||||
}
|
||||
22
dashboard/src/components/systems/EngineData.jsx
Normal file
22
dashboard/src/components/systems/EngineData.jsx
Normal 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)',
|
||||
}
|
||||
}
|
||||
40
dashboard/src/components/systems/ServiceHealth.jsx
Normal file
40
dashboard/src/components/systems/ServiceHealth.jsx
Normal 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' },
|
||||
}
|
||||
Reference in New Issue
Block a user