diff --git a/.gitignore b/.gitignore index 43e5898..2094db7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ dist/ -data/ +/data/ *.sqlite *.sqlite-journal *.sqlite-wal diff --git a/server/src/db/migrations/003_add_ship_fields.sql b/server/src/db/migrations/003_add_ship_fields.sql new file mode 100644 index 0000000..3cf5ba7 --- /dev/null +++ b/server/src/db/migrations/003_add_ship_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE ships ADD COLUMN crew_req INTEGER NOT NULL DEFAULT 0; +ALTER TABLE ships ADD COLUMN hardpoints INTEGER NOT NULL DEFAULT 0; +ALTER TABLE ships ADD COLUMN cargo INTEGER NOT NULL DEFAULT 0; diff --git a/server/src/services/ship-service.ts b/server/src/services/ship-service.ts index 83f0850..6f2dcfe 100644 --- a/server/src/services/ship-service.ts +++ b/server/src/services/ship-service.ts @@ -15,6 +15,9 @@ export interface Ship { maneuver_class: string | null; size_category: string | null; notes: string | null; + crew_req: number; + hardpoints: number; + cargo: number; updated_at: number; } @@ -56,8 +59,8 @@ export function createShip(input: CreateShipInput): Ship { const now = Date.now(); db.prepare(` - INSERT INTO ships (id, name, hull_current, hull_max, armor_current, armor_max, ac, con_save, speed, maneuver_class, size_category, notes, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO ships (id, name, hull_current, hull_max, armor_current, armor_max, ac, con_save, speed, maneuver_class, size_category, notes, crew_req, hardpoints, cargo, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.name, @@ -71,6 +74,9 @@ export function createShip(input: CreateShipInput): Ship { input.maneuver_class, input.size_category, input.notes, + input.crew_req, + input.hardpoints, + input.cargo, now, ); diff --git a/server/src/validation/ship-schema.ts b/server/src/validation/ship-schema.ts index 26f4c1b..7e0aeaa 100644 --- a/server/src/validation/ship-schema.ts +++ b/server/src/validation/ship-schema.ts @@ -14,6 +14,9 @@ export const createShipSchema = z.object({ maneuver_class: z.enum(maneuverClasses).nullable().default(null), size_category: z.string().max(50).nullable().default(null), notes: z.string().nullable().default(null), + crew_req: z.number().int().min(0).default(0), + hardpoints: z.number().int().min(0).default(0), + cargo: z.number().int().min(0).default(0), }); export const updateShipSchema = z @@ -29,6 +32,9 @@ export const updateShipSchema = z maneuver_class: z.enum(maneuverClasses).nullable(), size_category: z.string().max(50).nullable(), notes: z.string().nullable(), + crew_req: z.number().int().min(0), + hardpoints: z.number().int().min(0), + cargo: z.number().int().min(0), }) .partial() .refine( diff --git a/web/src/components/dashboard/CrewSection.tsx b/web/src/components/dashboard/CrewSection.tsx new file mode 100644 index 0000000..2a443ff --- /dev/null +++ b/web/src/components/dashboard/CrewSection.tsx @@ -0,0 +1,50 @@ +import NumericStepper from '../ui/NumericStepper'; +import type { Ship } from '../../types/ship'; + +interface Props { + ship: Ship; + onUpdate: (patch: Partial) => void; + editing: boolean; +} + +export default function CrewSection({ ship, onUpdate, editing }: Props) { + return ( +
+

Crew & Cargo

+ {editing ? ( +
+ onUpdate({ crew_req: v })} + /> + onUpdate({ hardpoints: v })} + /> + onUpdate({ cargo: v })} + /> +
+ ) : ( +
+
+ Crew + {ship.crew_req} +
+
+ Hardpoints + {ship.hardpoints} +
+
+ Cargo + {ship.cargo}t +
+
+ )} +
+ ); +} diff --git a/web/src/components/dashboard/MobilitySection.tsx b/web/src/components/dashboard/MobilitySection.tsx index 2a5aa2c..cf4697f 100644 --- a/web/src/components/dashboard/MobilitySection.tsx +++ b/web/src/components/dashboard/MobilitySection.tsx @@ -8,31 +8,49 @@ const SIZE_CATEGORIES = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan interface Props { ship: Ship; onUpdate: (patch: Partial) => void; + editing: boolean; } -export default function MobilitySection({ ship, onUpdate }: Props) { +export default function MobilitySection({ ship, onUpdate, editing }: Props) { return (

Mobility

-
- onUpdate({ speed: v })} - /> - onUpdate({ maneuver_class: v })} - /> - onUpdate({ size_category: v })} - /> -
+ {editing ? ( +
+ onUpdate({ speed: v })} + /> + onUpdate({ maneuver_class: v })} + /> + onUpdate({ size_category: v })} + /> +
+ ) : ( +
+
+ Speed + {ship.speed ?? 0}ft +
+
+ Class + {ship.maneuver_class ?? '—'} +
+
+ Size + {ship.size_category ?? '—'} +
+
+ )}
); } diff --git a/web/src/components/dashboard/VitalsSection.tsx b/web/src/components/dashboard/VitalsSection.tsx index 70578ec..8b0806f 100644 --- a/web/src/components/dashboard/VitalsSection.tsx +++ b/web/src/components/dashboard/VitalsSection.tsx @@ -4,48 +4,70 @@ import type { Ship } from '../../types/ship'; interface Props { ship: Ship; onUpdate: (patch: Partial) => void; + editing: boolean; } -export default function VitalsSection({ ship, onUpdate }: Props) { +export default function VitalsSection({ ship, onUpdate, editing }: 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 })} - /> -
+ {editing ? ( +
+ onUpdate({ hull_current: v })} + /> + onUpdate({ hull_max: v })} + /> + onUpdate({ armor_current: v })} + /> + onUpdate({ armor_max: v })} + /> + onUpdate({ ac: v })} + /> + onUpdate({ con_save: v })} + /> +
+ ) : ( +
+
+ Hull + {ship.hull_current}/{ship.hull_max} +
+
+ AC + {ship.ac} +
+
+ Armor + {ship.armor_current}/{ship.armor_max} +
+
+ Con + {ship.con_save != null ? `+${ship.con_save}` : '—'} +
+
+ )}
); } diff --git a/web/src/components/ships/CreateShipModal.tsx b/web/src/components/ships/CreateShipModal.tsx index b877f32..e74ff00 100644 --- a/web/src/components/ships/CreateShipModal.tsx +++ b/web/src/components/ships/CreateShipModal.tsx @@ -1,40 +1,108 @@ import { useState } from 'react'; import Modal from '../ui/Modal'; import { useShipsList } from '../../store/use-ships-list'; +import { shipTemplates } from '../../data/ship-templates'; +import type { ShipTemplate } from '../../data/ship-templates'; interface Props { open: boolean; onClose: () => void; } +const MANEUVER_CLASSES = ['S', 'A', 'B', 'C', 'D', 'E', 'F']; +const SIZE_CATEGORIES = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan']; + +function defaults(): FormState { + return { + name: '', + hullMax: '0', + armorMax: '0', + ac: '10', + conSave: '0', + speed: '300', + maneuverClass: '', + sizeCategory: '', + crewReq: '0', + hardpoints: '0', + cargo: '0', + }; +} + +function fromTemplate(t: ShipTemplate): FormState { + return { + name: t.name, + hullMax: String(t.hull_max), + armorMax: String(t.armor_max), + ac: String(t.ac), + conSave: String(t.con_save), + speed: String(t.speed), + maneuverClass: t.maneuver_class, + sizeCategory: t.size_category, + crewReq: String(t.crew_req), + hardpoints: String(t.hardpoints), + cargo: String(t.cargo), + }; +} + +interface FormState { + name: string; + hullMax: string; + armorMax: string; + ac: string; + conSave: string; + speed: string; + maneuverClass: string; + sizeCategory: string; + crewReq: string; + hardpoints: string; + cargo: string; +} + export default function CreateShipModal({ open, onClose }: Props) { - const [name, setName] = useState(''); - const [hullMax, setHullMax] = useState('0'); - const [armorMax, setArmorMax] = useState('0'); - const [ac, setAc] = useState('10'); + const [templateIdx, setTemplateIdx] = useState(''); + const [form, setForm] = useState(defaults()); const [submitting, setSubmitting] = useState(false); const createShip = useShipsList((s) => s.createShip); + const set = (field: keyof FormState) => (e: React.ChangeEvent) => { + setForm((f) => ({ ...f, [field]: e.target.value })); + }; + + const handleTemplateChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setTemplateIdx(val); + if (val === '') { + setForm(defaults()); + } else { + setForm(fromTemplate(shipTemplates[parseInt(val)])); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!name.trim()) return; + if (!form.name.trim()) return; setSubmitting(true); try { - const hMax = parseInt(hullMax) || 0; - const aMax = parseInt(armorMax) || 0; + const hMax = parseInt(form.hullMax) || 0; + const aMax = parseInt(form.armorMax) || 0; await createShip({ - name: name.trim(), + name: form.name.trim(), hull_max: hMax, hull_current: hMax, armor_max: aMax, armor_current: aMax, - ac: parseInt(ac) || 10, + ac: parseInt(form.ac) || 10, + con_save: parseInt(form.conSave) || 0, + speed: parseInt(form.speed) || 0, + maneuver_class: form.maneuverClass || null, + size_category: form.sizeCategory || null, + crew_req: parseInt(form.crewReq) || 0, + hardpoints: parseInt(form.hardpoints) || 0, + cargo: parseInt(form.cargo) || 0, }); - setName(''); - setHullMax('0'); - setArmorMax('0'); - setAc('10'); + setForm(defaults()); + setTemplateIdx(''); onClose(); } catch { // Error handled by store @@ -46,49 +114,91 @@ export default function CreateShipModal({ open, onClose }: Props) { return (
+ + - - - + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
-
diff --git a/web/src/data/ship-templates.ts b/web/src/data/ship-templates.ts new file mode 100644 index 0000000..1d6160f --- /dev/null +++ b/web/src/data/ship-templates.ts @@ -0,0 +1,146 @@ +export interface ShipTemplate { + name: string; + hull_max: number; + armor_max: number; + ac: number; + con_save: number; + speed: number; + maneuver_class: string; + size_category: string; + crew_req: number; + hardpoints: number; + cargo: number; +} + +export const shipTemplates: ShipTemplate[] = [ + { + name: 'Caravel', + hull_max: 50, + armor_max: 20, + ac: 13, + con_save: 3, + speed: 300, + maneuver_class: 'B', + size_category: 'Small', + crew_req: 8, + hardpoints: 4, + cargo: 15, + }, + { + name: 'Galleon', + hull_max: 75, + armor_max: 35, + ac: 13, + con_save: 4, + speed: 300, + maneuver_class: 'C', + size_category: 'Medium', + crew_req: 20, + hardpoints: 12, + cargo: 40, + }, + { + name: 'Sloop', + hull_max: 40, + armor_max: 16, + ac: 13, + con_save: 3, + speed: 300, + maneuver_class: 'B', + size_category: 'Small', + crew_req: 8, + hardpoints: 3, + cargo: 10, + }, + { + name: 'Frigate', + hull_max: 120, + armor_max: 40, + ac: 15, + con_save: 7, + speed: 300, + maneuver_class: 'B', + size_category: 'Medium', + crew_req: 20, + hardpoints: 12, + cargo: 100, + }, + { + name: 'Hammership', + hull_max: 150, + armor_max: 40, + ac: 15, + con_save: 7, + speed: 300, + maneuver_class: 'C', + size_category: 'Medium', + crew_req: 24, + hardpoints: 16, + cargo: 150, + }, + { + name: 'Squid Ship', + hull_max: 120, + armor_max: 35, + ac: 14, + con_save: 7, + speed: 300, + maneuver_class: 'C', + size_category: 'Medium', + crew_req: 14, + hardpoints: 7, + cargo: 60, + }, + { + name: 'Man-O-War', + hull_max: 75, + armor_max: 25, + ac: 15, + con_save: 6, + speed: 300, + maneuver_class: 'C', + size_category: 'Medium', + crew_req: 10, + hardpoints: 6, + cargo: 30, + }, + { + name: 'Bombard', + hull_max: 60, + armor_max: 35, + ac: 14, + con_save: 4, + speed: 300, + maneuver_class: 'C', + size_category: 'Small', + crew_req: 10, + hardpoints: 3, + cargo: 15, + }, + { + name: 'Shrike Ship', + hull_max: 40, + armor_max: 20, + ac: 15, + con_save: 3, + speed: 400, + maneuver_class: 'A', + size_category: 'Small', + crew_req: 5, + hardpoints: 3, + cargo: 5, + }, + { + name: 'Whaleship', + hull_max: 225, + armor_max: 50, + ac: 15, + con_save: 4, + speed: 200, + maneuver_class: 'D', + size_category: 'Large', + crew_req: 12, + hardpoints: 12, + cargo: 250, + }, +]; diff --git a/web/src/index.css b/web/src/index.css index d2b56b0..5c8f204 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -234,6 +234,12 @@ a:hover { color: var(--color-text-muted); } +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-sm); +} + /* Ship List */ .ship-list-header { margin-bottom: var(--spacing-md); @@ -315,12 +321,42 @@ button:disabled { padding-bottom: var(--spacing-xs); } +.dashboard-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-md); +} + .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-sm); } +.stat-grid-readonly { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xs) var(--spacing-md); +} + +.stat-ro { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: var(--spacing-xs) 0; +} + +.stat-ro-label { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.stat-ro-value { + font-size: 1rem; + font-weight: 600; + color: var(--color-text); +} + /* Stepper */ .stepper { display: flex; diff --git a/web/src/pages/ShipDashboardPage.tsx b/web/src/pages/ShipDashboardPage.tsx index 13e3d55..c93ab64 100644 --- a/web/src/pages/ShipDashboardPage.tsx +++ b/web/src/pages/ShipDashboardPage.tsx @@ -1,9 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useState } 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 CrewSection from '../components/dashboard/CrewSection'; import WeaponsSection from '../components/dashboard/WeaponsSection'; import NotesSection from '../components/dashboard/NotesSection'; import { useShipStore } from '../store/use-ship-store'; @@ -11,6 +12,7 @@ import { useShipStore } from '../store/use-ship-store'; export default function ShipDashboardPage() { const { id } = useParams<{ id: string }>(); const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore(); + const [editing, setEditing] = useState(false); useEffect(() => { if (id) joinShip(id); @@ -32,8 +34,18 @@ export default function ShipDashboardPage() { <> - - +
+ +
+ + +