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>