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:
Bas van Rossem
2026-02-19 16:26:47 +01:00
parent 88e9bf7f05
commit 1ef2f6338c
7 changed files with 499 additions and 2 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -380,3 +380,99 @@ button:disabled {
height: 36px;
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;
}

View File

@@ -4,12 +4,14 @@ 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 WeaponsSection from '../components/dashboard/WeaponsSection';
import NotesSection from '../components/dashboard/NotesSection';
import { useShipStore } from '../store/use-ship-store';
import type { Ship } from '../types/ship';
export default function ShipDashboardPage() {
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);
useEffect(() => {
@@ -45,7 +47,13 @@ export default function ShipDashboardPage() {
<PageContainer>
<VitalsSection 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>
</>
);