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:
13
src/App.js
13
src/App.js
@@ -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}
|
||||
/>}
|
||||
|
||||
221
src/components/WeaponsTab.jsx
Normal file
221
src/components/WeaponsTab.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user