feat: ship templates, crew fields, and read/edit dashboard

- Add crew_req, hardpoints, cargo fields (DB migration + server + types)
- Add 10 ship templates from Appendix A (Caravel, Galleon, Sloop, etc.)
- Enhanced CreateShipModal with template picker that auto-fills all stats
- Dashboard defaults to compact read-only view; Edit button toggles to
  editable steppers/dropdowns
- New CrewSection showing crew required, hardpoints, and cargo
- Two-column form layout in create modal for better use of space
- Fix .gitignore data/ pattern to only match root-level data directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 17:16:44 +01:00
parent cbda07d793
commit 642f1f70e8
12 changed files with 524 additions and 112 deletions

View File

@@ -0,0 +1,50 @@
import NumericStepper from '../ui/NumericStepper';
import type { Ship } from '../../types/ship';
interface Props {
ship: Ship;
onUpdate: (patch: Partial<Ship>) => void;
editing: boolean;
}
export default function CrewSection({ ship, onUpdate, editing }: Props) {
return (
<section className="dashboard-section">
<h3 className="section-title">Crew &amp; Cargo</h3>
{editing ? (
<div className="stat-grid">
<NumericStepper
label="Crew Required"
value={ship.crew_req}
onChange={(v) => onUpdate({ crew_req: v })}
/>
<NumericStepper
label="Hardpoints"
value={ship.hardpoints}
onChange={(v) => onUpdate({ hardpoints: v })}
/>
<NumericStepper
label="Cargo (tons)"
value={ship.cargo}
onChange={(v) => onUpdate({ cargo: v })}
/>
</div>
) : (
<div className="stat-grid-readonly">
<div className="stat-ro">
<span className="stat-ro-label">Crew</span>
<span className="stat-ro-value">{ship.crew_req}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Hardpoints</span>
<span className="stat-ro-value">{ship.hardpoints}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Cargo</span>
<span className="stat-ro-value">{ship.cargo}t</span>
</div>
</div>
)}
</section>
);
}

View File

@@ -8,31 +8,49 @@ const SIZE_CATEGORIES = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan
interface Props {
ship: Ship;
onUpdate: (patch: Partial<Ship>) => void;
editing: boolean;
}
export default function MobilitySection({ ship, onUpdate }: Props) {
export default function MobilitySection({ ship, onUpdate, editing }: Props) {
return (
<section className="dashboard-section">
<h3 className="section-title">Mobility</h3>
<div className="stat-grid">
<NumericStepper
label="Speed"
value={ship.speed ?? 0}
onChange={(v) => onUpdate({ speed: v })}
/>
<EnumDropdown
label="Maneuver Class"
value={ship.maneuver_class}
options={MANEUVER_CLASSES}
onChange={(v) => onUpdate({ maneuver_class: v })}
/>
<EnumDropdown
label="Size Category"
value={ship.size_category}
options={SIZE_CATEGORIES}
onChange={(v) => onUpdate({ size_category: v })}
/>
</div>
{editing ? (
<div className="stat-grid">
<NumericStepper
label="Speed"
value={ship.speed ?? 0}
onChange={(v) => onUpdate({ speed: v })}
/>
<EnumDropdown
label="Maneuver Class"
value={ship.maneuver_class}
options={MANEUVER_CLASSES}
onChange={(v) => onUpdate({ maneuver_class: v })}
/>
<EnumDropdown
label="Size Category"
value={ship.size_category}
options={SIZE_CATEGORIES}
onChange={(v) => onUpdate({ size_category: v })}
/>
</div>
) : (
<div className="stat-grid-readonly">
<div className="stat-ro">
<span className="stat-ro-label">Speed</span>
<span className="stat-ro-value">{ship.speed ?? 0}ft</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Class</span>
<span className="stat-ro-value">{ship.maneuver_class ?? '—'}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Size</span>
<span className="stat-ro-value">{ship.size_category ?? '—'}</span>
</div>
</div>
)}
</section>
);
}

View File

@@ -4,48 +4,70 @@ import type { Ship } from '../../types/ship';
interface Props {
ship: Ship;
onUpdate: (patch: Partial<Ship>) => void;
editing: boolean;
}
export default function VitalsSection({ ship, onUpdate }: Props) {
export default function VitalsSection({ ship, onUpdate, editing }: Props) {
return (
<section className="dashboard-section">
<h3 className="section-title">Vitals</h3>
<div className="stat-grid">
<NumericStepper
label="Hull"
value={ship.hull_current}
max={ship.hull_max}
onChange={(v) => onUpdate({ hull_current: v })}
/>
<NumericStepper
label="Hull Max"
value={ship.hull_max}
min={ship.hull_current}
onChange={(v) => onUpdate({ hull_max: v })}
/>
<NumericStepper
label="Armor"
value={ship.armor_current}
max={ship.armor_max}
onChange={(v) => onUpdate({ armor_current: v })}
/>
<NumericStepper
label="Armor Max"
value={ship.armor_max}
min={ship.armor_current}
onChange={(v) => onUpdate({ armor_max: v })}
/>
<NumericStepper
label="AC"
value={ship.ac}
onChange={(v) => onUpdate({ ac: v })}
/>
<NumericStepper
label="Con Save"
value={ship.con_save ?? 0}
onChange={(v) => onUpdate({ con_save: v })}
/>
</div>
{editing ? (
<div className="stat-grid">
<NumericStepper
label="Hull"
value={ship.hull_current}
max={ship.hull_max}
onChange={(v) => onUpdate({ hull_current: v })}
/>
<NumericStepper
label="Hull Max"
value={ship.hull_max}
min={ship.hull_current}
onChange={(v) => onUpdate({ hull_max: v })}
/>
<NumericStepper
label="Armor"
value={ship.armor_current}
max={ship.armor_max}
onChange={(v) => onUpdate({ armor_current: v })}
/>
<NumericStepper
label="Armor Max"
value={ship.armor_max}
min={ship.armor_current}
onChange={(v) => onUpdate({ armor_max: v })}
/>
<NumericStepper
label="AC"
value={ship.ac}
onChange={(v) => onUpdate({ ac: v })}
/>
<NumericStepper
label="Con Save"
value={ship.con_save ?? 0}
onChange={(v) => onUpdate({ con_save: v })}
/>
</div>
) : (
<div className="stat-grid-readonly">
<div className="stat-ro">
<span className="stat-ro-label">Hull</span>
<span className="stat-ro-value">{ship.hull_current}/{ship.hull_max}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">AC</span>
<span className="stat-ro-value">{ship.ac}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Armor</span>
<span className="stat-ro-value">{ship.armor_current}/{ship.armor_max}</span>
</div>
<div className="stat-ro">
<span className="stat-ro-label">Con</span>
<span className="stat-ro-value">{ship.con_save != null ? `+${ship.con_save}` : '—'}</span>
</div>
</div>
)}
</section>
);
}

View File

@@ -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<string>('');
const [form, setForm] = useState<FormState>(defaults());
const [submitting, setSubmitting] = useState(false);
const createShip = useShipsList((s) => s.createShip);
const set = (field: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm((f) => ({ ...f, [field]: e.target.value }));
};
const handleTemplateChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
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 (
<Modal open={open} onClose={onClose} title="Create Ship">
<form onSubmit={handleSubmit} className="modal-form">
<label className="form-label">
Template
<select value={templateIdx} onChange={handleTemplateChange}>
<option value="">Custom</option>
{shipTemplates.map((t, i) => (
<option key={t.name} value={i}>{t.name}</option>
))}
</select>
</label>
<label className="form-label">
Ship Name *
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Astral Clipper"
autoFocus
required
/>
</label>
<label className="form-label">
Hull Max
<input
type="number"
value={hullMax}
onChange={(e) => setHullMax(e.target.value)}
min="0"
/>
</label>
<label className="form-label">
Armor Max
<input
type="number"
value={armorMax}
onChange={(e) => setArmorMax(e.target.value)}
min="0"
/>
</label>
<label className="form-label">
AC
<input
type="number"
value={ac}
onChange={(e) => setAc(e.target.value)}
min="0"
/>
<input type="text" value={form.name} onChange={set('name')} placeholder="e.g. Astral Clipper" autoFocus required />
</label>
<div className="form-row">
<label className="form-label">
Hull Max
<input type="number" value={form.hullMax} onChange={set('hullMax')} min="0" />
</label>
<label className="form-label">
Armor Max
<input type="number" value={form.armorMax} onChange={set('armorMax')} min="0" />
</label>
</div>
<div className="form-row">
<label className="form-label">
AC
<input type="number" value={form.ac} onChange={set('ac')} min="0" />
</label>
<label className="form-label">
Con Save
<input type="number" value={form.conSave} onChange={set('conSave')} />
</label>
</div>
<div className="form-row">
<label className="form-label">
Speed (ft)
<input type="number" value={form.speed} onChange={set('speed')} min="0" step="50" />
</label>
<label className="form-label">
Maneuver Class
<select value={form.maneuverClass} onChange={set('maneuverClass')}>
<option value=""></option>
{MANEUVER_CLASSES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
</div>
<div className="form-row">
<label className="form-label">
Size
<select value={form.sizeCategory} onChange={set('sizeCategory')}>
<option value=""></option>
{SIZE_CATEGORIES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label className="form-label">
Crew Required
<input type="number" value={form.crewReq} onChange={set('crewReq')} min="0" />
</label>
</div>
<div className="form-row">
<label className="form-label">
Hardpoints
<input type="number" value={form.hardpoints} onChange={set('hardpoints')} min="0" />
</label>
<label className="form-label">
Cargo (tons)
<input type="number" value={form.cargo} onChange={set('cargo')} min="0" />
</label>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={submitting || !name.trim()}>
<button type="submit" className="btn-primary" disabled={submitting || !form.name.trim()}>
{submitting ? 'Creating...' : 'Create Ship'}
</button>
</div>

View File

@@ -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,
},
];

View File

@@ -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;

View File

@@ -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() {
<>
<TopBar title={ship.name} />
<PageContainer>
<VitalsSection ship={ship} onUpdate={updateShip} />
<MobilitySection ship={ship} onUpdate={updateShip} />
<div className="dashboard-toolbar">
<button
type="button"
className={editing ? 'btn-primary btn-sm' : 'btn-secondary btn-sm'}
onClick={() => setEditing(!editing)}
>
{editing ? 'Done' : 'Edit'}
</button>
</div>
<VitalsSection ship={ship} onUpdate={updateShip} editing={editing} />
<MobilitySection ship={ship} onUpdate={updateShip} editing={editing} />
<CrewSection ship={ship} onUpdate={updateShip} editing={editing} />
<WeaponsSection
weapons={weapons}
onCreateWeapon={createWeapon}

View File

@@ -11,6 +11,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;
}