feat(web): implement weapons section with add/edit/detail modals and notes
Adds WeaponCard with inline ammo stepper and status dropdown, AddWeaponModal, WeaponDetailModal with full editing, and debounced NotesSection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
38
web/src/components/dashboard/NotesSection.tsx
Normal file
38
web/src/components/dashboard/NotesSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { Ship } from '../../types/ship';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
notes: string | null;
|
||||||
|
onUpdate: (patch: Partial<Ship>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotesSection({ notes, onUpdate }: Props) {
|
||||||
|
const [value, setValue] = useState(notes ?? '');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Sync from server updates
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(notes ?? '');
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
const handleChange = (text: string) => {
|
||||||
|
setValue(text);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
onUpdate({ notes: text || null });
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dashboard-section">
|
||||||
|
<h3 className="section-title">Notes</h3>
|
||||||
|
<textarea
|
||||||
|
className="notes-textarea"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="Ship notes..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/components/dashboard/WeaponsSection.tsx
Normal file
61
web/src/components/dashboard/WeaponsSection.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import WeaponCard from '../weapons/WeaponCard';
|
||||||
|
import AddWeaponModal from '../weapons/AddWeaponModal';
|
||||||
|
import WeaponDetailModal from '../weapons/WeaponDetailModal';
|
||||||
|
import type { Weapon } from '../../types/weapon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weapons: Weapon[];
|
||||||
|
onCreateWeapon: (weapon: Record<string, unknown>) => void;
|
||||||
|
onUpdateWeapon: (weaponId: string, patch: Partial<Weapon>) => void;
|
||||||
|
onDeleteWeapon: (weaponId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeaponsSection({
|
||||||
|
weapons,
|
||||||
|
onCreateWeapon,
|
||||||
|
onUpdateWeapon,
|
||||||
|
onDeleteWeapon,
|
||||||
|
}: Props) {
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [selectedWeapon, setSelectedWeapon] = useState<Weapon | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="dashboard-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3 className="section-title">Weapons</h3>
|
||||||
|
<button className="btn-primary btn-sm" onClick={() => setShowAdd(true)}>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weapons.length === 0 && (
|
||||||
|
<p className="empty-text" style={{ padding: 'var(--spacing-md) 0' }}>
|
||||||
|
No weapons. Add one above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="weapons-list">
|
||||||
|
{weapons.map((w) => (
|
||||||
|
<WeaponCard
|
||||||
|
key={w.id}
|
||||||
|
weapon={w}
|
||||||
|
onAmmoChange={(id, ammo) => onUpdateWeapon(id, { ammo_current: ammo })}
|
||||||
|
onStatusChange={(id, status) => onUpdateWeapon(id, { status })}
|
||||||
|
onTap={setSelectedWeapon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddWeaponModal open={showAdd} onClose={() => setShowAdd(false)} onCreate={onCreateWeapon} />
|
||||||
|
|
||||||
|
<WeaponDetailModal
|
||||||
|
weapon={selectedWeapon}
|
||||||
|
open={!!selectedWeapon}
|
||||||
|
onClose={() => setSelectedWeapon(null)}
|
||||||
|
onUpdate={onUpdateWeapon}
|
||||||
|
onDelete={onDeleteWeapon}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
web/src/components/weapons/AddWeaponModal.tsx
Normal file
100
web/src/components/weapons/AddWeaponModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (weapon: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddWeaponModal({ open, onClose, onCreate }: Props) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [damage, setDamage] = useState('');
|
||||||
|
const [attackMod, setAttackMod] = useState('');
|
||||||
|
const [range, setRange] = useState('');
|
||||||
|
const [ammoMax, setAmmoMax] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const aMax = parseInt(ammoMax) || null;
|
||||||
|
onCreate({
|
||||||
|
name: name.trim(),
|
||||||
|
damage: damage.trim() || null,
|
||||||
|
attack_mod: parseInt(attackMod) || null,
|
||||||
|
range: range.trim() || null,
|
||||||
|
ammo_max: aMax,
|
||||||
|
ammo_current: aMax,
|
||||||
|
status: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
setDamage('');
|
||||||
|
setAttackMod('');
|
||||||
|
setRange('');
|
||||||
|
setAmmoMax('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Add Weapon">
|
||||||
|
<form onSubmit={handleSubmit} className="modal-form">
|
||||||
|
<label className="form-label">
|
||||||
|
Name *
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Ballista"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Damage
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={damage}
|
||||||
|
onChange={(e) => setDamage(e.target.value)}
|
||||||
|
placeholder="e.g. 3d10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Attack Modifier
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={attackMod}
|
||||||
|
onChange={(e) => setAttackMod(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Range
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={range}
|
||||||
|
onChange={(e) => setRange(e.target.value)}
|
||||||
|
placeholder="e.g. 120/480"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Ammo Max (leave empty for unlimited)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ammoMax}
|
||||||
|
onChange={(e) => setAmmoMax(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={!name.trim()}>
|
||||||
|
Add Weapon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
web/src/components/weapons/WeaponCard.tsx
Normal file
57
web/src/components/weapons/WeaponCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Weapon } from '../../types/weapon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weapon: Weapon;
|
||||||
|
onAmmoChange: (weaponId: string, ammo: number) => void;
|
||||||
|
onStatusChange: (weaponId: string, status: string) => void;
|
||||||
|
onTap: (weapon: Weapon) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ['ok', 'damaged', 'disabled'];
|
||||||
|
|
||||||
|
export default function WeaponCard({ weapon, onAmmoChange, onStatusChange, onTap }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="weapon-card" onClick={() => onTap(weapon)}>
|
||||||
|
<div className="weapon-card-header">
|
||||||
|
<span className="weapon-card-name">{weapon.name}</span>
|
||||||
|
{weapon.damage && <span className="weapon-card-damage">{weapon.damage}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="weapon-card-controls" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{weapon.ammo_max !== null && (
|
||||||
|
<div className="weapon-ammo">
|
||||||
|
<button
|
||||||
|
className="stepper-btn stepper-btn-sm"
|
||||||
|
onClick={() => onAmmoChange(weapon.id, Math.max(0, (weapon.ammo_current ?? 0) - 1))}
|
||||||
|
disabled={(weapon.ammo_current ?? 0) <= 0}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="weapon-ammo-value">
|
||||||
|
{weapon.ammo_current ?? 0}/{weapon.ammo_max}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="stepper-btn stepper-btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
onAmmoChange(weapon.id, Math.min(weapon.ammo_max!, (weapon.ammo_current ?? 0) + 1))
|
||||||
|
}
|
||||||
|
disabled={(weapon.ammo_current ?? 0) >= (weapon.ammo_max ?? 0)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className="weapon-status-select"
|
||||||
|
value={weapon.status ?? 'ok'}
|
||||||
|
onChange={(e) => onStatusChange(weapon.id, e.target.value)}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
web/src/components/weapons/WeaponDetailModal.tsx
Normal file
137
web/src/components/weapons/WeaponDetailModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import type { Weapon } from '../../types/weapon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weapon: Weapon | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (weaponId: string, patch: Partial<Weapon>) => void;
|
||||||
|
onDelete: (weaponId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeaponDetailModal({ weapon, open, onClose, onUpdate, onDelete }: Props) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [type, setType] = useState('');
|
||||||
|
const [damage, setDamage] = useState('');
|
||||||
|
const [attackMod, setAttackMod] = useState('');
|
||||||
|
const [range, setRange] = useState('');
|
||||||
|
const [ammoMax, setAmmoMax] = useState('');
|
||||||
|
const [ammoCurrent, setAmmoCurrent] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (weapon) {
|
||||||
|
setName(weapon.name);
|
||||||
|
setType(weapon.type ?? '');
|
||||||
|
setDamage(weapon.damage ?? '');
|
||||||
|
setAttackMod(weapon.attack_mod?.toString() ?? '');
|
||||||
|
setRange(weapon.range ?? '');
|
||||||
|
setAmmoMax(weapon.ammo_max?.toString() ?? '');
|
||||||
|
setAmmoCurrent(weapon.ammo_current?.toString() ?? '');
|
||||||
|
setNotes(weapon.notes ?? '');
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
}, [weapon]);
|
||||||
|
|
||||||
|
if (!weapon) return null;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const patch: Partial<Weapon> = {};
|
||||||
|
if (name.trim() !== weapon.name) patch.name = name.trim();
|
||||||
|
if ((type.trim() || null) !== weapon.type) patch.type = type.trim() || null;
|
||||||
|
if ((damage.trim() || null) !== weapon.damage) patch.damage = damage.trim() || null;
|
||||||
|
const aMod = parseInt(attackMod) || null;
|
||||||
|
if (aMod !== weapon.attack_mod) patch.attack_mod = aMod;
|
||||||
|
if ((range.trim() || null) !== weapon.range) patch.range = range.trim() || null;
|
||||||
|
const aMax = ammoMax ? parseInt(ammoMax) : null;
|
||||||
|
if (aMax !== weapon.ammo_max) patch.ammo_max = aMax;
|
||||||
|
const aCur = ammoCurrent ? parseInt(ammoCurrent) : null;
|
||||||
|
if (aCur !== weapon.ammo_current) patch.ammo_current = aCur;
|
||||||
|
if ((notes.trim() || null) !== weapon.notes) patch.notes = notes.trim() || null;
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
onUpdate(weapon.id, patch);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDelete(weapon.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Weapon Details">
|
||||||
|
<div className="modal-form">
|
||||||
|
<label className="form-label">
|
||||||
|
Name
|
||||||
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Type
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
placeholder="e.g. siege, arcane"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Damage
|
||||||
|
<input type="text" value={damage} onChange={(e) => setDamage(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Attack Modifier
|
||||||
|
<input type="number" value={attackMod} onChange={(e) => setAttackMod(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Range
|
||||||
|
<input type="text" value={range} onChange={(e) => setRange(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Ammo Current
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ammoCurrent}
|
||||||
|
onChange={(e) => setAmmoCurrent(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Ammo Max
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ammoMax}
|
||||||
|
onChange={(e) => setAmmoMax(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
Notes
|
||||||
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
|
||||||
|
</label>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{confirmDelete ? 'Confirm Delete' : 'Delete Weapon'}
|
||||||
|
</button>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -380,3 +380,99 @@ button:disabled {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section header with inline button */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header .section-title {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weapons */
|
||||||
|
.weapons-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card-damage {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-card-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-ammo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-ammo-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-btn-sm {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-status-select {
|
||||||
|
width: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.notes-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ 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 WeaponsSection from '../components/dashboard/WeaponsSection';
|
||||||
|
import NotesSection from '../components/dashboard/NotesSection';
|
||||||
import { useShipStore } from '../store/use-ship-store';
|
import { useShipStore } from '../store/use-ship-store';
|
||||||
import type { Ship } from '../types/ship';
|
import type { Ship } from '../types/ship';
|
||||||
|
|
||||||
export default function ShipDashboardPage() {
|
export default function ShipDashboardPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { ship, loading, joinShip, leaveShip, updateShip } = useShipStore();
|
const { ship, weapons, loading, joinShip, leaveShip, updateShip, createWeapon, updateWeapon, deleteWeapon } = useShipStore();
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,7 +47,13 @@ export default function ShipDashboardPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<VitalsSection ship={ship} onUpdate={handleUpdate} />
|
<VitalsSection ship={ship} onUpdate={handleUpdate} />
|
||||||
<MobilitySection ship={ship} onUpdate={handleUpdate} />
|
<MobilitySection ship={ship} onUpdate={handleUpdate} />
|
||||||
{/* Weapons and Notes sections will be added next */}
|
<WeaponsSection
|
||||||
|
weapons={weapons}
|
||||||
|
onCreateWeapon={createWeapon}
|
||||||
|
onUpdateWeapon={updateWeapon}
|
||||||
|
onDeleteWeapon={deleteWeapon}
|
||||||
|
/>
|
||||||
|
<NotesSection notes={ship.notes} onUpdate={handleUpdate} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user