From 06428f79cdda04814ad8502a6129e8df7dbb04da Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:21:22 +0100 Subject: [PATCH] feat(web): add zustand stores and Socket.IO client Implements ship list store (REST-based) and ship detail store (Socket.IO-based) with room join/leave, reconnection handling, and real-time event listeners. Co-Authored-By: Claude Opus 4.6 --- web/src/socket.ts | 13 ++++ web/src/store/use-ship-store.ts | 128 ++++++++++++++++++++++++++++++++ web/src/store/use-ships-list.ts | 54 ++++++++++++++ web/src/types/ship.ts | 21 ++++++ web/src/types/weapon.ts | 15 ++++ 5 files changed, 231 insertions(+) create mode 100644 web/src/socket.ts create mode 100644 web/src/store/use-ship-store.ts create mode 100644 web/src/store/use-ships-list.ts create mode 100644 web/src/types/ship.ts create mode 100644 web/src/types/weapon.ts diff --git a/web/src/socket.ts b/web/src/socket.ts new file mode 100644 index 0000000..c0ce574 --- /dev/null +++ b/web/src/socket.ts @@ -0,0 +1,13 @@ +import { io } from 'socket.io-client'; + +// In development, Vite proxies /socket.io to the server. +// In production, both are served from the same origin. +const socket = io({ + autoConnect: true, + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, +}); + +export default socket; diff --git a/web/src/store/use-ship-store.ts b/web/src/store/use-ship-store.ts new file mode 100644 index 0000000..99fc269 --- /dev/null +++ b/web/src/store/use-ship-store.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand'; +import type { Ship } from '../types/ship'; +import type { Weapon } from '../types/weapon'; +import socket from '../socket'; + +interface ShipStore { + ship: Ship | null; + weapons: Weapon[]; + loading: boolean; + currentShipId: string | null; + joinShip: (id: string) => void; + leaveShip: () => void; + updateShip: (patch: Partial) => void; + createWeapon: (weapon: Record) => void; + updateWeapon: (weaponId: string, patch: Partial) => void; + deleteWeapon: (weaponId: string) => void; +} + +export const useShipStore = create((set, get) => ({ + ship: null, + weapons: [], + loading: false, + currentShipId: null, + + joinShip: (id: string) => { + const current = get().currentShipId; + if (current === id) return; + if (current) { + // Leave previous room first + get().leaveShip(); + } + + set({ loading: true, currentShipId: id, ship: null, weapons: [] }); + + // Register socket listeners + const onState = (data: { ship: Ship; weapons: Weapon[] }) => { + set({ ship: data.ship, weapons: data.weapons, loading: false }); + }; + + const onPatched = (data: { shipId: string; patch: Partial; updated_at: number }) => { + const state = get(); + if (state.ship && state.ship.id === data.shipId) { + set({ ship: { ...state.ship, ...data.patch, updated_at: data.updated_at } }); + } + }; + + const onWeaponCreated = (data: { shipId: string; weapon: Weapon }) => { + set({ weapons: [...get().weapons, data.weapon] }); + }; + + const onWeaponPatched = (data: { + shipId: string; + weaponId: string; + patch: Partial; + updated_at: number; + }) => { + set({ + weapons: get().weapons.map((w) => + w.id === data.weaponId ? { ...w, ...data.patch, updated_at: data.updated_at } : w, + ), + }); + }; + + const onWeaponDeleted = (data: { shipId: string; weaponId: string }) => { + set({ weapons: get().weapons.filter((w) => w.id !== data.weaponId) }); + }; + + socket.on('ship:state', onState); + socket.on('ship:patched', onPatched); + socket.on('weapon:created', onWeaponCreated); + socket.on('weapon:patched', onWeaponPatched); + socket.on('weapon:deleted', onWeaponDeleted); + + // Join the room + socket.emit('ship:join', { shipId: id }); + + // Re-join on reconnect + const onReconnect = () => { + if (get().currentShipId === id) { + socket.emit('ship:join', { shipId: id }); + } + }; + socket.on('connect', onReconnect); + + // Store cleanup references + (socket as any).__shipCleanup = () => { + socket.off('ship:state', onState); + socket.off('ship:patched', onPatched); + socket.off('weapon:created', onWeaponCreated); + socket.off('weapon:patched', onWeaponPatched); + socket.off('weapon:deleted', onWeaponDeleted); + socket.off('connect', onReconnect); + }; + }, + + leaveShip: () => { + const id = get().currentShipId; + if (id) { + socket.emit('ship:leave', { shipId: id }); + } + // Clean up listeners + if ((socket as any).__shipCleanup) { + (socket as any).__shipCleanup(); + delete (socket as any).__shipCleanup; + } + set({ ship: null, weapons: [], currentShipId: null, loading: false }); + }, + + updateShip: (patch) => { + const id = get().currentShipId; + if (!id) return; + socket.emit('ship:update', { shipId: id, patch }); + }, + + createWeapon: (weapon) => { + const id = get().currentShipId; + if (!id) return; + socket.emit('weapon:create', { shipId: id, weapon }); + }, + + updateWeapon: (weaponId, patch) => { + socket.emit('weapon:update', { weaponId, patch }); + }, + + deleteWeapon: (weaponId) => { + socket.emit('weapon:delete', { weaponId }); + }, +})); diff --git a/web/src/store/use-ships-list.ts b/web/src/store/use-ships-list.ts new file mode 100644 index 0000000..42da3a9 --- /dev/null +++ b/web/src/store/use-ships-list.ts @@ -0,0 +1,54 @@ +import { create } from 'zustand'; +import type { ShipListItem } from '../types/ship'; + +interface ShipsListState { + ships: ShipListItem[]; + loading: boolean; + error: string | null; + fetchShips: () => Promise; + createShip: (data: Record) => Promise; + deleteShip: (id: string) => Promise; +} + +export const useShipsList = create((set, get) => ({ + ships: [], + loading: false, + error: null, + + fetchShips: async () => { + set({ loading: true, error: null }); + try { + const res = await fetch('/api/ships'); + if (!res.ok) throw new Error('Failed to fetch ships'); + const ships = await res.json(); + set({ ships, loading: false }); + } catch (err: any) { + set({ error: err.message, loading: false }); + } + }, + + createShip: async (data) => { + const res = await fetch('/api/ships', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to create ship'); + } + const ship = await res.json(); + // Add to list + set({ ships: [{ id: ship.id, name: ship.name, updated_at: ship.updated_at }, ...get().ships] }); + return ship; + }, + + deleteShip: async (id) => { + const res = await fetch(`/api/ships/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to delete ship'); + } + set({ ships: get().ships.filter((s) => s.id !== id) }); + }, +})); diff --git a/web/src/types/ship.ts b/web/src/types/ship.ts new file mode 100644 index 0000000..62d3e90 --- /dev/null +++ b/web/src/types/ship.ts @@ -0,0 +1,21 @@ +export interface Ship { + id: string; + name: string; + hull_current: number; + hull_max: number; + armor_current: number; + armor_max: number; + ac: number; + con_save: number | null; + speed: number | null; + maneuver_class: string | null; + size_category: string | null; + notes: string | null; + updated_at: number; +} + +export interface ShipListItem { + id: string; + name: string; + updated_at: number; +} diff --git a/web/src/types/weapon.ts b/web/src/types/weapon.ts new file mode 100644 index 0000000..84fa22f --- /dev/null +++ b/web/src/types/weapon.ts @@ -0,0 +1,15 @@ +export interface Weapon { + id: string; + ship_id: string; + name: string; + type: string | null; + attack_mod: number | null; + damage: string | null; + range: string | null; + ammo_current: number | null; + ammo_max: number | null; + status: string | null; + notes: string | null; + sort_order: number; + updated_at: number; +}