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 <noreply@anthropic.com>
This commit is contained in:
13
web/src/socket.ts
Normal file
13
web/src/socket.ts
Normal file
@@ -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;
|
||||
128
web/src/store/use-ship-store.ts
Normal file
128
web/src/store/use-ship-store.ts
Normal file
@@ -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<Ship>) => void;
|
||||
createWeapon: (weapon: Record<string, unknown>) => void;
|
||||
updateWeapon: (weaponId: string, patch: Partial<Weapon>) => void;
|
||||
deleteWeapon: (weaponId: string) => void;
|
||||
}
|
||||
|
||||
export const useShipStore = create<ShipStore>((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<Ship>; 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<Weapon>;
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
54
web/src/store/use-ships-list.ts
Normal file
54
web/src/store/use-ships-list.ts
Normal file
@@ -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<void>;
|
||||
createShip: (data: Record<string, unknown>) => Promise<ShipListItem>;
|
||||
deleteShip: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useShipsList = create<ShipsListState>((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) });
|
||||
},
|
||||
}));
|
||||
21
web/src/types/ship.ts
Normal file
21
web/src/types/ship.ts
Normal file
@@ -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;
|
||||
}
|
||||
15
web/src/types/weapon.ts
Normal file
15
web/src/types/weapon.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user