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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
data/
|
/data/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-journal
|
*.sqlite-journal
|
||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
|
|||||||
3
server/src/db/migrations/003_add_ship_fields.sql
Normal file
3
server/src/db/migrations/003_add_ship_fields.sql
Normal file
@@ -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;
|
||||||
@@ -15,6 +15,9 @@ export interface Ship {
|
|||||||
maneuver_class: string | null;
|
maneuver_class: string | null;
|
||||||
size_category: string | null;
|
size_category: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
crew_req: number;
|
||||||
|
hardpoints: number;
|
||||||
|
cargo: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +59,8 @@ export function createShip(input: CreateShipInput): Ship {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
db.prepare(`
|
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
input.name,
|
input.name,
|
||||||
@@ -71,6 +74,9 @@ export function createShip(input: CreateShipInput): Ship {
|
|||||||
input.maneuver_class,
|
input.maneuver_class,
|
||||||
input.size_category,
|
input.size_category,
|
||||||
input.notes,
|
input.notes,
|
||||||
|
input.crew_req,
|
||||||
|
input.hardpoints,
|
||||||
|
input.cargo,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const createShipSchema = z.object({
|
|||||||
maneuver_class: z.enum(maneuverClasses).nullable().default(null),
|
maneuver_class: z.enum(maneuverClasses).nullable().default(null),
|
||||||
size_category: z.string().max(50).nullable().default(null),
|
size_category: z.string().max(50).nullable().default(null),
|
||||||
notes: z.string().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
|
export const updateShipSchema = z
|
||||||
@@ -29,6 +32,9 @@ export const updateShipSchema = z
|
|||||||
maneuver_class: z.enum(maneuverClasses).nullable(),
|
maneuver_class: z.enum(maneuverClasses).nullable(),
|
||||||
size_category: z.string().max(50).nullable(),
|
size_category: z.string().max(50).nullable(),
|
||||||
notes: z.string().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()
|
.partial()
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
50
web/src/components/dashboard/CrewSection.tsx
Normal file
50
web/src/components/dashboard/CrewSection.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,31 +8,49 @@ const SIZE_CATEGORIES = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan
|
|||||||
interface Props {
|
interface Props {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
onUpdate: (patch: Partial<Ship>) => void;
|
onUpdate: (patch: Partial<Ship>) => void;
|
||||||
|
editing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobilitySection({ ship, onUpdate }: Props) {
|
export default function MobilitySection({ ship, onUpdate, editing }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="dashboard-section">
|
<section className="dashboard-section">
|
||||||
<h3 className="section-title">Mobility</h3>
|
<h3 className="section-title">Mobility</h3>
|
||||||
<div className="stat-grid">
|
{editing ? (
|
||||||
<NumericStepper
|
<div className="stat-grid">
|
||||||
label="Speed"
|
<NumericStepper
|
||||||
value={ship.speed ?? 0}
|
label="Speed"
|
||||||
onChange={(v) => onUpdate({ speed: v })}
|
value={ship.speed ?? 0}
|
||||||
/>
|
onChange={(v) => onUpdate({ speed: v })}
|
||||||
<EnumDropdown
|
/>
|
||||||
label="Maneuver Class"
|
<EnumDropdown
|
||||||
value={ship.maneuver_class}
|
label="Maneuver Class"
|
||||||
options={MANEUVER_CLASSES}
|
value={ship.maneuver_class}
|
||||||
onChange={(v) => onUpdate({ maneuver_class: v })}
|
options={MANEUVER_CLASSES}
|
||||||
/>
|
onChange={(v) => onUpdate({ maneuver_class: v })}
|
||||||
<EnumDropdown
|
/>
|
||||||
label="Size Category"
|
<EnumDropdown
|
||||||
value={ship.size_category}
|
label="Size Category"
|
||||||
options={SIZE_CATEGORIES}
|
value={ship.size_category}
|
||||||
onChange={(v) => onUpdate({ size_category: v })}
|
options={SIZE_CATEGORIES}
|
||||||
/>
|
onChange={(v) => onUpdate({ size_category: v })}
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,48 +4,70 @@ import type { Ship } from '../../types/ship';
|
|||||||
interface Props {
|
interface Props {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
onUpdate: (patch: Partial<Ship>) => void;
|
onUpdate: (patch: Partial<Ship>) => void;
|
||||||
|
editing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VitalsSection({ ship, onUpdate }: Props) {
|
export default function VitalsSection({ ship, onUpdate, editing }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="dashboard-section">
|
<section className="dashboard-section">
|
||||||
<h3 className="section-title">Vitals</h3>
|
<h3 className="section-title">Vitals</h3>
|
||||||
<div className="stat-grid">
|
{editing ? (
|
||||||
<NumericStepper
|
<div className="stat-grid">
|
||||||
label="Hull"
|
<NumericStepper
|
||||||
value={ship.hull_current}
|
label="Hull"
|
||||||
max={ship.hull_max}
|
value={ship.hull_current}
|
||||||
onChange={(v) => onUpdate({ hull_current: v })}
|
max={ship.hull_max}
|
||||||
/>
|
onChange={(v) => onUpdate({ hull_current: v })}
|
||||||
<NumericStepper
|
/>
|
||||||
label="Hull Max"
|
<NumericStepper
|
||||||
value={ship.hull_max}
|
label="Hull Max"
|
||||||
min={ship.hull_current}
|
value={ship.hull_max}
|
||||||
onChange={(v) => onUpdate({ hull_max: v })}
|
min={ship.hull_current}
|
||||||
/>
|
onChange={(v) => onUpdate({ hull_max: v })}
|
||||||
<NumericStepper
|
/>
|
||||||
label="Armor"
|
<NumericStepper
|
||||||
value={ship.armor_current}
|
label="Armor"
|
||||||
max={ship.armor_max}
|
value={ship.armor_current}
|
||||||
onChange={(v) => onUpdate({ armor_current: v })}
|
max={ship.armor_max}
|
||||||
/>
|
onChange={(v) => onUpdate({ armor_current: v })}
|
||||||
<NumericStepper
|
/>
|
||||||
label="Armor Max"
|
<NumericStepper
|
||||||
value={ship.armor_max}
|
label="Armor Max"
|
||||||
min={ship.armor_current}
|
value={ship.armor_max}
|
||||||
onChange={(v) => onUpdate({ armor_max: v })}
|
min={ship.armor_current}
|
||||||
/>
|
onChange={(v) => onUpdate({ armor_max: v })}
|
||||||
<NumericStepper
|
/>
|
||||||
label="AC"
|
<NumericStepper
|
||||||
value={ship.ac}
|
label="AC"
|
||||||
onChange={(v) => onUpdate({ ac: v })}
|
value={ship.ac}
|
||||||
/>
|
onChange={(v) => onUpdate({ ac: v })}
|
||||||
<NumericStepper
|
/>
|
||||||
label="Con Save"
|
<NumericStepper
|
||||||
value={ship.con_save ?? 0}
|
label="Con Save"
|
||||||
onChange={(v) => onUpdate({ con_save: v })}
|
value={ship.con_save ?? 0}
|
||||||
/>
|
onChange={(v) => onUpdate({ con_save: v })}
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,108 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import { useShipsList } from '../../store/use-ships-list';
|
import { useShipsList } from '../../store/use-ships-list';
|
||||||
|
import { shipTemplates } from '../../data/ship-templates';
|
||||||
|
import type { ShipTemplate } from '../../data/ship-templates';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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) {
|
export default function CreateShipModal({ open, onClose }: Props) {
|
||||||
const [name, setName] = useState('');
|
const [templateIdx, setTemplateIdx] = useState<string>('');
|
||||||
const [hullMax, setHullMax] = useState('0');
|
const [form, setForm] = useState<FormState>(defaults());
|
||||||
const [armorMax, setArmorMax] = useState('0');
|
|
||||||
const [ac, setAc] = useState('10');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const createShip = useShipsList((s) => s.createShip);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) return;
|
if (!form.name.trim()) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const hMax = parseInt(hullMax) || 0;
|
const hMax = parseInt(form.hullMax) || 0;
|
||||||
const aMax = parseInt(armorMax) || 0;
|
const aMax = parseInt(form.armorMax) || 0;
|
||||||
await createShip({
|
await createShip({
|
||||||
name: name.trim(),
|
name: form.name.trim(),
|
||||||
hull_max: hMax,
|
hull_max: hMax,
|
||||||
hull_current: hMax,
|
hull_current: hMax,
|
||||||
armor_max: aMax,
|
armor_max: aMax,
|
||||||
armor_current: 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('');
|
setForm(defaults());
|
||||||
setHullMax('0');
|
setTemplateIdx('');
|
||||||
setArmorMax('0');
|
|
||||||
setAc('10');
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
// Error handled by store
|
// Error handled by store
|
||||||
@@ -46,49 +114,91 @@ export default function CreateShipModal({ open, onClose }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title="Create Ship">
|
<Modal open={open} onClose={onClose} title="Create Ship">
|
||||||
<form onSubmit={handleSubmit} className="modal-form">
|
<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">
|
<label className="form-label">
|
||||||
Ship Name *
|
Ship Name *
|
||||||
<input
|
<input type="text" value={form.name} onChange={set('name')} placeholder="e.g. Astral Clipper" autoFocus required />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</label>
|
</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">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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'}
|
{submitting ? 'Creating...' : 'Create Ship'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
146
web/src/data/ship-templates.ts
Normal file
146
web/src/data/ship-templates.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -234,6 +234,12 @@ a:hover {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Ship List */
|
/* Ship List */
|
||||||
.ship-list-header {
|
.ship-list-header {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
@@ -315,12 +321,42 @@ button:disabled {
|
|||||||
padding-bottom: var(--spacing-xs);
|
padding-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--spacing-sm);
|
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 */
|
||||||
.stepper {
|
.stepper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import TopBar from '../components/layout/TopBar';
|
import TopBar from '../components/layout/TopBar';
|
||||||
import PageContainer from '../components/layout/PageContainer';
|
import PageContainer from '../components/layout/PageContainer';
|
||||||
import VitalsSection from '../components/dashboard/VitalsSection';
|
import VitalsSection from '../components/dashboard/VitalsSection';
|
||||||
import MobilitySection from '../components/dashboard/MobilitySection';
|
import MobilitySection from '../components/dashboard/MobilitySection';
|
||||||
|
import CrewSection from '../components/dashboard/CrewSection';
|
||||||
import WeaponsSection from '../components/dashboard/WeaponsSection';
|
import WeaponsSection from '../components/dashboard/WeaponsSection';
|
||||||
import NotesSection from '../components/dashboard/NotesSection';
|
import NotesSection from '../components/dashboard/NotesSection';
|
||||||
import { useShipStore } from '../store/use-ship-store';
|
import { useShipStore } from '../store/use-ship-store';
|
||||||
@@ -11,6 +12,7 @@ import { useShipStore } from '../store/use-ship-store';
|
|||||||
export default function ShipDashboardPage() {
|
export default function ShipDashboardPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore();
|
const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) joinShip(id);
|
if (id) joinShip(id);
|
||||||
@@ -32,8 +34,18 @@ export default function ShipDashboardPage() {
|
|||||||
<>
|
<>
|
||||||
<TopBar title={ship.name} />
|
<TopBar title={ship.name} />
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<VitalsSection ship={ship} onUpdate={updateShip} />
|
<div className="dashboard-toolbar">
|
||||||
<MobilitySection ship={ship} onUpdate={updateShip} />
|
<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
|
<WeaponsSection
|
||||||
weapons={weapons}
|
weapons={weapons}
|
||||||
onCreateWeapon={createWeapon}
|
onCreateWeapon={createWeapon}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface Ship {
|
|||||||
maneuver_class: string | null;
|
maneuver_class: string | null;
|
||||||
size_category: string | null;
|
size_category: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
crew_req: number;
|
||||||
|
hardpoints: number;
|
||||||
|
cargo: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user