Add Weapons tab showing all 73 weapons from rulebooks

Adds a new WeaponsTab component with:
- Filterable table of all 73 weapons from scrape-rulebooks.py
- Filter by category: Ranged, Melee, Grenade, Armour, Other
- Text search across name, damage, special rules, source
- Click-to-expand detail panel with full stats
- Parses both ranged (semicolon-separated) and melee (structured) stat formats

Tab is visible to all users (not GM-restricted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 19:43:19 +01:00
parent 585584d6c2
commit ea0ce62290
2 changed files with 231 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import RequisitionShop from './components/RequisitionShop';
import PlayerTab from './components/PlayerTab';
import RulesTab from './components/RulesTab';
import BestiaryTab from './components/BestiaryTab';
import WeaponsTab from './components/WeaponsTab';
import GMKit from './components/GMKit';
import PlayerManagement from './components/PlayerManagement';
import { useState, useEffect } from 'react';
@@ -283,12 +284,18 @@ function App() {
>
Character Sheet
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='rules' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='rules' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'rules' }); setTab('rules')}}
>
Rules
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='weapons' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'weapons' }); setTab('weapons')}}
>
Weapons
</button>
{authedPlayer === 'gm' && (
<>
<button
@@ -348,7 +355,7 @@ function App() {
</div>
)}
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
authedPlayer={authedPlayer}
sessionId={sessionId}
/>}

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect, useMemo } from 'react'
function parseRangedStats(dmgStr) {
// Format: "Range 50m; S/3/-; 1d10+9 X; Pen 6; Clip 36; Reload 2 Full; Tearing"
if (!dmgStr) return {}
const parts = dmgStr.split(';').map(s => s.trim())
const result = {}
for (const part of parts) {
if (/^Range /i.test(part)) result.range = part.replace(/^Range /i, '')
else if (/^Pen /i.test(part)) result.pen = part.replace(/^Pen /i, '')
else if (/^Clip /i.test(part)) result.clip = part.replace(/^Clip /i, '')
else if (/^Reload /i.test(part)) result.reload = part.replace(/^Reload /i, '')
else if (/^[S-]\/[0-9-]/.test(part)) result.rof = part
else if (/\dd\d/.test(part)) result.damage = part
else if (part) result.special = (result.special ? result.special + ', ' : '') + part
}
return result
}
function WeaponRow({ weapon, onSelect }) {
const s = weapon.stats || {}
const isRanged = weapon.category === 'Ranged Weapon' || weapon.category === 'Grenade'
const isMelee = weapon.category === 'Melee Weapon'
// For ranged/grenades stored as single string
const parsed = (isRanged && s.damage && s.damage.includes(';')) ? parseRangedStats(s.damage) : null
const dmg = parsed ? parsed.damage : s.damage
const range = parsed ? parsed.range : s.range
const rof = parsed ? parsed.rof : s.rof
const clip = parsed ? parsed.clip : s.clip
const reload = parsed ? parsed.reload : s.rld
const pen = parsed ? parsed.pen : s.pen
const special = parsed ? (parsed.special || '') : (s.special || '')
const cls = s.class || weapon.category
return (
<tr
className="border-b border-slate-700 hover:bg-slate-700/40 cursor-pointer transition-colors"
onClick={() => onSelect(weapon)}
>
<td className="py-2 px-3 font-medium text-blue-200">{weapon.name}</td>
<td className="py-2 px-3 text-slate-300 text-sm">{cls}</td>
{isMelee ? (
<>
<td className="py-2 px-2 text-slate-200 text-sm"></td>
<td className="py-2 px-2 text-slate-200 text-sm"></td>
<td className="py-2 px-2 text-slate-200 text-sm"></td>
<td className="py-2 px-2 text-slate-200 text-sm"></td>
</>
) : (
<>
<td className="py-2 px-2 text-slate-200 text-sm">{range || '—'}</td>
<td className="py-2 px-2 text-slate-200 text-sm">{rof || '—'}</td>
<td className="py-2 px-2 text-slate-200 text-sm">{clip || '—'}</td>
<td className="py-2 px-2 text-slate-200 text-sm">{reload || '—'}</td>
</>
)}
<td className="py-2 px-2 text-yellow-300 text-sm font-mono">{dmg || '—'}</td>
<td className="py-2 px-2 text-slate-200 text-sm">{pen || s.pen || '—'}</td>
<td className="py-2 px-3 text-slate-400 text-xs max-w-xs truncate">{special || '—'}</td>
<td className="py-2 px-2 text-slate-500 text-xs">{weapon.source?.replace('Deathwatch ', '').replace(' Core Rulebook', ' CR') || '—'}</td>
</tr>
)
}
function WeaponDetail({ weapon, onClose }) {
if (!weapon) return null
const s = weapon.stats || {}
const isRanged = weapon.category === 'Ranged Weapon' || weapon.category === 'Grenade'
const parsed = (isRanged && s.damage && s.damage.includes(';')) ? parseRangedStats(s.damage) : null
const fields = parsed ? [
['Range', parsed.range],
['RoF', parsed.rof],
['Damage', parsed.damage],
['Pen', parsed.pen],
['Clip', parsed.clip],
['Reload', parsed.reload],
['Special', parsed.special],
['Class', s.class],
] : [
['Damage', s.damage],
['Pen', s.pen],
['Range', s.range],
['RoF', s.rof],
['Clip', s.clip],
['Reload', s.rld],
['Special', s.special],
['Weight', s.wt && s.wt !== '-' ? s.wt + ' kg' : null],
['Requisition', s.req],
['Renown', s.renown],
['Class', s.class],
]
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50" onClick={onClose}>
<div className="bg-slate-800 rounded-xl p-6 max-w-lg w-full border border-slate-600 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-blue-200">{weapon.name}</h2>
<span className="text-sm text-slate-400">{weapon.category} · {weapon.source}</span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white text-2xl leading-none ml-4">×</button>
</div>
<div className="grid grid-cols-2 gap-2">
{fields.filter(([, v]) => v && v !== '-' && v !== '—').map(([label, value]) => (
<div key={label} className="bg-slate-700 rounded p-2">
<div className="text-xs text-slate-400 mb-1">{label}</div>
<div className="text-sm text-slate-100 font-medium">{value}</div>
</div>
))}
</div>
</div>
</div>
)
}
const CATS = ['All', 'Ranged Weapon', 'Melee Weapon', 'Grenade', 'Armour', 'Other']
export default function WeaponsTab() {
const [weapons, setWeapons] = useState([])
const [loading, setLoading] = useState(true)
const [q, setQ] = useState('')
const [cat, setCat] = useState('All')
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/weapons')
.then(r => r.json())
.then(data => { setWeapons(data); setLoading(false) })
.catch(e => { setError('Failed to load weapons'); setLoading(false) })
}, [])
const filtered = useMemo(() => {
let list = weapons
if (cat !== 'All') list = list.filter(w => w.category === cat)
if (q.trim()) {
const term = q.toLowerCase()
list = list.filter(w =>
w.name.toLowerCase().includes(term) ||
(w.stats?.damage || '').toLowerCase().includes(term) ||
(w.stats?.special || '').toLowerCase().includes(term) ||
(w.source || '').toLowerCase().includes(term)
)
}
return list
}, [weapons, cat, q])
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
<div className="mx-auto max-w-7xl">
<div className="mb-6">
<h2 className="text-3xl font-bold mb-1">Weapons</h2>
<p className="text-slate-400 text-sm">{weapons.length} weapons from all Deathwatch sourcebooks</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<input
type="text"
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search weapons..."
className="px-4 py-2 rounded-lg bg-slate-800 border border-slate-600 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
/>
<div className="flex gap-2 flex-wrap">
{CATS.map(c => (
<button
key={c}
onClick={() => setCat(c)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${cat === c ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}
>
{c}
</button>
))}
</div>
</div>
{loading && <div className="text-slate-400 py-8 text-center">Loading weapons...</div>}
{error && <div className="text-red-400 py-8 text-center">{error}</div>}
{!loading && !error && (
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-600 bg-slate-900/50">
<th className="py-3 px-3 text-slate-300 font-semibold">Name</th>
<th className="py-3 px-3 text-slate-300 font-semibold">Class</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Range</th>
<th className="py-3 px-2 text-slate-300 font-semibold">RoF</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Clip</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Rld</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Damage</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Pen</th>
<th className="py-3 px-3 text-slate-300 font-semibold">Special</th>
<th className="py-3 px-2 text-slate-300 font-semibold">Source</th>
</tr>
</thead>
<tbody>
{filtered.map(w => (
<WeaponRow key={w.id} weapon={w} onSelect={setSelected} />
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="py-12 text-center text-slate-500">No weapons match your filter.</div>
)}
</div>
)}
<div className="mt-3 text-xs text-slate-600">
Showing {filtered.length} of {weapons.length} weapons · Click a row for details
</div>
</div>
{selected && <WeaponDetail weapon={selected} onClose={() => setSelected(null)} />}
</section>
)
}