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;
|
||||
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 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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user