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/
|
||||
dist/
|
||||
data/
|
||||
/data/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.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;
|
||||
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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user