From 88e9bf7f0567fd8e84783b756a2914e0f0e0821d Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:24:53 +0100 Subject: [PATCH] feat(web): implement Ship Dashboard with vitals and mobility sections Adds NumericStepper and EnumDropdown UI components, VitalsSection (hull/armor/AC/con), MobilitySection (speed/maneuver class/size), with debounced real-time updates. Co-Authored-By: Claude Opus 4.6 --- .../components/dashboard/MobilitySection.tsx | 38 +++++++++ .../components/dashboard/VitalsSection.tsx | 51 ++++++++++++ web/src/components/ui/EnumDropdown.tsx | 35 ++++++++ web/src/components/ui/NumericStepper.tsx | 45 ++++++++++ web/src/index.css | 82 +++++++++++++++++++ web/src/pages/ShipDashboardPage.tsx | 40 ++++++++- 6 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 web/src/components/dashboard/MobilitySection.tsx create mode 100644 web/src/components/dashboard/VitalsSection.tsx create mode 100644 web/src/components/ui/EnumDropdown.tsx create mode 100644 web/src/components/ui/NumericStepper.tsx diff --git a/web/src/components/dashboard/MobilitySection.tsx b/web/src/components/dashboard/MobilitySection.tsx new file mode 100644 index 0000000..2a5aa2c --- /dev/null +++ b/web/src/components/dashboard/MobilitySection.tsx @@ -0,0 +1,38 @@ +import NumericStepper from '../ui/NumericStepper'; +import EnumDropdown from '../ui/EnumDropdown'; +import type { Ship } from '../../types/ship'; + +const MANEUVER_CLASSES = ['S', 'A', 'B', 'C', 'D', 'E', 'F']; +const SIZE_CATEGORIES = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan']; + +interface Props { + ship: Ship; + onUpdate: (patch: Partial) => void; +} + +export default function MobilitySection({ ship, onUpdate }: Props) { + return ( +
+

Mobility

+
+ onUpdate({ speed: v })} + /> + onUpdate({ maneuver_class: v })} + /> + onUpdate({ size_category: v })} + /> +
+
+ ); +} diff --git a/web/src/components/dashboard/VitalsSection.tsx b/web/src/components/dashboard/VitalsSection.tsx new file mode 100644 index 0000000..70578ec --- /dev/null +++ b/web/src/components/dashboard/VitalsSection.tsx @@ -0,0 +1,51 @@ +import NumericStepper from '../ui/NumericStepper'; +import type { Ship } from '../../types/ship'; + +interface Props { + ship: Ship; + onUpdate: (patch: Partial) => void; +} + +export default function VitalsSection({ ship, onUpdate }: Props) { + return ( +
+

Vitals

+
+ onUpdate({ hull_current: v })} + /> + onUpdate({ hull_max: v })} + /> + onUpdate({ armor_current: v })} + /> + onUpdate({ armor_max: v })} + /> + onUpdate({ ac: v })} + /> + onUpdate({ con_save: v })} + /> +
+
+ ); +} diff --git a/web/src/components/ui/EnumDropdown.tsx b/web/src/components/ui/EnumDropdown.tsx new file mode 100644 index 0000000..15576bb --- /dev/null +++ b/web/src/components/ui/EnumDropdown.tsx @@ -0,0 +1,35 @@ +interface Props { + label: string; + value: string | null; + options: string[]; + onChange: (value: string | null) => void; + allowNull?: boolean; + nullLabel?: string; +} + +export default function EnumDropdown({ + label, + value, + options, + onChange, + allowNull = true, + nullLabel = '—', +}: Props) { + return ( +
+ {label} + +
+ ); +} diff --git a/web/src/components/ui/NumericStepper.tsx b/web/src/components/ui/NumericStepper.tsx new file mode 100644 index 0000000..0c99759 --- /dev/null +++ b/web/src/components/ui/NumericStepper.tsx @@ -0,0 +1,45 @@ +interface Props { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; +} + +export default function NumericStepper({ label, value, onChange, min = 0, max, step = 1 }: Props) { + const decrement = () => { + const next = value - step; + onChange(min !== undefined ? Math.max(min, next) : next); + }; + + const increment = () => { + const next = value + step; + onChange(max !== undefined ? Math.min(max, next) : next); + }; + + return ( +
+ {label} +
+ + {value} + +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index 635a182..e32d4f5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -298,3 +298,85 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Dashboard */ +.dashboard-section { + margin-bottom: var(--spacing-lg); +} + +.section-title { + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--spacing-xs); +} + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-sm); +} + +/* Stepper */ +.stepper { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.stepper-label { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.stepper-controls { + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.stepper-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + color: var(--color-text); + font-size: 1.2rem; + font-weight: bold; + border-radius: var(--radius); + padding: 0; +} + +.stepper-btn:hover:not(:disabled) { + background: var(--color-surface-hover); +} + +.stepper-value { + min-width: 40px; + text-align: center; + font-weight: 600; + font-size: 1.1rem; +} + +/* Dropdown */ +.dropdown-field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.dropdown-label { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.dropdown-select { + height: 36px; + padding: var(--spacing-xs) var(--spacing-sm); +} diff --git a/web/src/pages/ShipDashboardPage.tsx b/web/src/pages/ShipDashboardPage.tsx index 0c715b6..1258562 100644 --- a/web/src/pages/ShipDashboardPage.tsx +++ b/web/src/pages/ShipDashboardPage.tsx @@ -1,15 +1,51 @@ +import { useEffect, useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import TopBar from '../components/layout/TopBar'; import PageContainer from '../components/layout/PageContainer'; +import VitalsSection from '../components/dashboard/VitalsSection'; +import MobilitySection from '../components/dashboard/MobilitySection'; +import { useShipStore } from '../store/use-ship-store'; +import type { Ship } from '../types/ship'; export default function ShipDashboardPage() { const { id } = useParams<{ id: string }>(); + const { ship, loading, joinShip, leaveShip, updateShip } = useShipStore(); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (id) joinShip(id); + return () => leaveShip(); + }, [id, joinShip, leaveShip]); + + const handleUpdate = useCallback( + (patch: Partial) => { + // Debounce rapid changes (e.g. stepper clicks) + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + updateShip(patch); + }, 300); + }, + [updateShip], + ); + + if (loading || !ship) { + return ( + <> + + +

Loading ship data...

+
+ + ); + } return ( <> - + -

Dashboard for ship {id} coming soon...

+ + + {/* Weapons and Notes sections will be added next */}
);