Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/* Add any app-specific styles here */
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import './App.css';
|
||||
import DeathwatchRoller from './components/DeathwatchRoller';
|
||||
import RequisitionShop from './components/RequisitionShop';
|
||||
import PlayerTab from './components/PlayerTab';
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [tab, setTab] = useState('roller');
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<div className="mx-auto max-w-6xl px-6 pt-4">
|
||||
<div className="flex gap-2">
|
||||
<button className={`px-3 py-1.5 rounded ${tab==='roller' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-100'}`} onClick={()=>setTab('roller')}>Roller</button>
|
||||
<button className={`px-3 py-1.5 rounded ${tab==='shop' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-100'}`} onClick={()=>setTab('shop')}>Requisition Shop</button>
|
||||
<button className={`px-3 py-1.5 rounded ${tab==='player' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-100'}`} onClick={()=>setTab('player')}>Player</button>
|
||||
</div>
|
||||
</div>
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop /> : <PlayerTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders deathwatch roller app', () => {
|
||||
render(<App />);
|
||||
const titleElement = screen.getByText(/Deathwatch Roller Pro/i);
|
||||
expect(titleElement).toBeInTheDocument();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,637 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios';
|
||||
|
||||
const STORAGE_PLAYER = 'dw:player:v1'
|
||||
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer'
|
||||
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
|
||||
|
||||
|
||||
function safeGet(key) {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !('localStorage' in window)) return null
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch { return null }
|
||||
}
|
||||
function safeSet(key, val) { try { if (typeof window !== 'undefined' && 'localStorage' in window) window.localStorage.setItem(key, JSON.stringify(val)) } catch {} }
|
||||
function safeRemove(key) { try { if (typeof window !== 'undefined' && 'localStorage' in window) window.localStorage.removeItem(key) } catch {} }
|
||||
|
||||
const CHARACTERISTICS = [
|
||||
{ key: 'ws', label: 'Weapon Skill (WS)' },
|
||||
{ key: 'bs', label: 'Ballistic Skill (BS)' },
|
||||
{ key: 's', label: 'Strength (S)' },
|
||||
{ key: 't', label: 'Toughness (T)' },
|
||||
{ key: 'ag', label: 'Agility (Ag)' },
|
||||
{ key: 'int', label: 'Intelligence (Int)' },
|
||||
{ key: 'per', label: 'Perception (Per)' },
|
||||
{ key: 'wp', label: 'Will Power (WP)' },
|
||||
{ key: 'fel', label: 'Fellowship (Fel)' },
|
||||
]
|
||||
|
||||
// Updated default skills to Space Marine skills
|
||||
const SKILLS = [
|
||||
'Acrobatics (Ag)', 'Awareness (Per)', 'Charm (Fel)', 'Climb (S)', 'Command (Fel)',
|
||||
'Common Lore (Int)', 'Deathwatch', 'Imperium', 'War', 'Dodge (Ag)',
|
||||
'Forbidden Lore (Int)', 'Xenos', 'Intimidate (S)', 'Literacy (Int)', 'Medicae (Int)',
|
||||
'Navigation (Int)', 'Pilot (Ag)', 'Scholastic Lore (Int)', 'Codex Astartes',
|
||||
'Scrutiny (Per)', 'Search (Per)', 'Silent Move (Ag)', 'Speak Language (Int)',
|
||||
'High Gothic', 'Low Gothic', 'Survival (Int)', 'Tactics (Int)', 'Tracking (Int)',
|
||||
'Tech-Use (Int)'
|
||||
]
|
||||
|
||||
// Updated PlayerTab to clear data unless logged in and maintain session across all tabs
|
||||
function PlayerTab() {
|
||||
// helpers to read shop state
|
||||
function getShopAuthed() {
|
||||
return safeGet(STORAGE_SHOP_AUTHED) || '';
|
||||
}
|
||||
|
||||
// UI state
|
||||
const init = safeGet(STORAGE_PLAYER) || {};
|
||||
const [charName, setCharName] = useState(init.charName || '');
|
||||
const [playerName, setPlayerName] = useState(init.playerName || '');
|
||||
const [shopAuthed, setShopAuthed] = useState(getShopAuthed());
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
|
||||
const [gear, setGear] = useState(init.gear || []);
|
||||
const [chapter, setChapter] = useState(init.chapter || '');
|
||||
const [demeanour, setDemeanour] = useState(init.demeanour || '');
|
||||
const [speciality, setSpeciality] = useState(init.speciality || '');
|
||||
const [rank, setRank] = useState(init.rank || '');
|
||||
const [powerArmour, setPowerArmour] = useState(init.powerArmour || '');
|
||||
const [description, setDescription] = useState(init.description || '');
|
||||
const [pastEvent, setPastEvent] = useState(init.pastEvent || '');
|
||||
const [personalDemeanour, setPersonalDemeanour] = useState(init.personalDemeanour || '');
|
||||
const [characteristics, setCharacteristics] = useState(init.characteristics || {});
|
||||
const [skills, setSkills] = useState(init.skills || []);
|
||||
const [weapons, setWeapons] = useState(init.weapons || []);
|
||||
const [armour, setArmour] = useState(init.armour || {});
|
||||
const [talents, setTalents] = useState(init.talents || '');
|
||||
const [psychic, setPsychic] = useState(init.psychic || '');
|
||||
const [wounds, setWounds] = useState(init.wounds || {});
|
||||
const [insanity, setInsanity] = useState(init.insanity || {});
|
||||
const [movement, setMovement] = useState(init.movement || {});
|
||||
const [fate, setFate] = useState(init.fate || {});
|
||||
const [corruption, setCorruption] = useState(init.corruption || 0);
|
||||
const [renown, setRenown] = useState(init.renown || '');
|
||||
const [xp, setXp] = useState(init.xp || 0);
|
||||
const [xpSpent, setXpSpent] = useState(init.xpSpent || 0);
|
||||
const [notes, setNotes] = useState(init.notes || '');
|
||||
|
||||
// Defined STORAGE_SHOP_PLAYERS and utilized setPlayers
|
||||
const [players, setPlayers] = useState(() => safeGet(STORAGE_SHOP_PLAYERS) || []);
|
||||
|
||||
useEffect(() => {
|
||||
safeSet(STORAGE_SHOP_PLAYERS, players);
|
||||
}, [players]);
|
||||
|
||||
const currentPlayer = useMemo(() => players.find(p => p.name === shopAuthed) || null, [players, shopAuthed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shopAuthed) {
|
||||
setCharName('');
|
||||
setPlayerName('');
|
||||
setGear([]);
|
||||
setChapter('');
|
||||
setDemeanour('');
|
||||
setSpeciality('');
|
||||
setRank('');
|
||||
setPowerArmour('');
|
||||
setDescription('');
|
||||
setPastEvent('');
|
||||
setPersonalDemeanour('');
|
||||
setCharacteristics({});
|
||||
setSkills([]);
|
||||
setWeapons([]);
|
||||
setArmour({});
|
||||
setTalents('');
|
||||
setPsychic('');
|
||||
setWounds({});
|
||||
setInsanity({});
|
||||
setMovement({});
|
||||
setFate({});
|
||||
setCorruption(0);
|
||||
setRenown('');
|
||||
setXp(0);
|
||||
setXpSpent(0);
|
||||
setNotes('');
|
||||
setSaveMsg('');
|
||||
}
|
||||
}, [shopAuthed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shopAuthed) {
|
||||
const currentPlayer = players.find(p => p.name === shopAuthed);
|
||||
if (currentPlayer) {
|
||||
setGear(currentPlayer.inventory || []);
|
||||
}
|
||||
}
|
||||
}, [players, shopAuthed]);
|
||||
|
||||
function handleCharChange(key, value) {
|
||||
setCharacteristics(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSkillChange(skill, level, value) {
|
||||
setSkills(prev => ({
|
||||
...prev,
|
||||
[skill]: {
|
||||
...prev[skill],
|
||||
[level]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function handleWeaponChange(idx, field, value) {
|
||||
setWeapons(prev => {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function handleArmourChange(part, value) {
|
||||
setArmour(prev => ({ ...prev, [part]: value }));
|
||||
}
|
||||
|
||||
function updateGear(id, updates) {
|
||||
if (!isGMOrShopAuthed()) {
|
||||
flash('Only GM or shop can update gear');
|
||||
return;
|
||||
}
|
||||
setGear(prev => prev.map(item => (item.id === id ? { ...item, ...updates } : item)));
|
||||
}
|
||||
|
||||
function deleteGear(id) {
|
||||
setGear(prev => prev.filter(item => item.id !== id));
|
||||
}
|
||||
|
||||
function addGearItem(name) {
|
||||
setGear(prev => [...prev, { id: Date.now(), name, qty: 1 }]);
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
setSaveMsg(msg);
|
||||
setTimeout(() => setSaveMsg(''), 2000);
|
||||
}
|
||||
|
||||
// Fetch players from the database
|
||||
useEffect(() => {
|
||||
async function fetchPlayers() {
|
||||
try {
|
||||
const response = await axios.get('/api/players');
|
||||
setPlayers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch players:', error);
|
||||
}
|
||||
}
|
||||
fetchPlayers();
|
||||
}, []);
|
||||
|
||||
// Update player data in the database
|
||||
async function updatePlayerData(updatedPlayer) {
|
||||
try {
|
||||
await axios.put(`/api/players/${updatedPlayer.name}`, updatedPlayer);
|
||||
setPlayers(prevPlayers => {
|
||||
const index = prevPlayers.findIndex(p => p.name === updatedPlayer.name);
|
||||
if (index !== -1) {
|
||||
const newPlayers = [...prevPlayers];
|
||||
newPlayers[index] = updatedPlayer;
|
||||
return newPlayers;
|
||||
}
|
||||
return [...prevPlayers, updatedPlayer];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update player:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Updated saveLocal to persist all player data including requisition points
|
||||
async function saveLocal() {
|
||||
if (!shopAuthed) {
|
||||
flash('Not logged in');
|
||||
return;
|
||||
}
|
||||
const playerData = {
|
||||
charName,
|
||||
playerName,
|
||||
gear,
|
||||
chapter,
|
||||
demeanour,
|
||||
speciality,
|
||||
rank,
|
||||
powerArmour,
|
||||
description,
|
||||
pastEvent,
|
||||
personalDemeanour,
|
||||
characteristics,
|
||||
skills,
|
||||
weapons,
|
||||
armour,
|
||||
talents,
|
||||
psychic,
|
||||
wounds,
|
||||
insanity,
|
||||
movement,
|
||||
fate,
|
||||
corruption,
|
||||
renown,
|
||||
xp,
|
||||
xpSpent,
|
||||
notes,
|
||||
rp: currentPlayer?.rp || 0,
|
||||
};
|
||||
await updatePlayerData(playerData);
|
||||
flash('Player data saved');
|
||||
}
|
||||
|
||||
function logoutShop() {
|
||||
safeRemove(STORAGE_SHOP_AUTHED);
|
||||
setShopAuthed('');
|
||||
flash('Logged out');
|
||||
}
|
||||
|
||||
// Added GM Panel button and Requisition Points display
|
||||
const [gmOpen, setGmOpen] = useState(false);
|
||||
const [playerPw, setPlayerPw] = useState('');
|
||||
|
||||
function isGMOrShopAuthed() {
|
||||
return gmOpen || shopAuthed;
|
||||
}
|
||||
|
||||
function isGMLoggedIn() {
|
||||
return gmOpen;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
safeSet('dw:gm:session', gmOpen);
|
||||
}, [gmOpen]);
|
||||
|
||||
async function handlePlayerLogin() {
|
||||
const player = players.find(p => p.name === playerName);
|
||||
if (player && player.pw === playerPw) {
|
||||
setShopAuthed(playerName);
|
||||
flash('Player logged in');
|
||||
} else {
|
||||
flash('Invalid player credentials');
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayerLogout() {
|
||||
safeRemove(STORAGE_SHOP_AUTHED);
|
||||
setShopAuthed('');
|
||||
flash('Player logged out');
|
||||
}
|
||||
|
||||
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 space-y-6">
|
||||
{/* Player Login Section */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-xs uppercase opacity-70">Player Login</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<select className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={playerName} onChange={e => setPlayerName(e.target.value)}>
|
||||
<option value="">Select player</option>
|
||||
{players.map(p => (<option key={p.name} value={p.name}>{p.name}</option>))}
|
||||
</select>
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Password" value={playerPw} onChange={e => setPlayerPw(e.target.value)} />
|
||||
{!shopAuthed ? (
|
||||
<button onClick={handlePlayerLogin} className="rounded-xl px-3 py-2 bg-blue-600 hover:bg-blue-500">Login</button>
|
||||
) : (
|
||||
<button onClick={handlePlayerLogout} className="rounded-xl px-3 py-2 bg-slate-700 hover:bg-slate-600">Logout ({shopAuthed})</button>
|
||||
)}
|
||||
<div className="rounded-xl bg-white/10 px-3 py-2 text-sm">
|
||||
RP: <span className="font-semibold">{currentPlayer ? (currentPlayer.rp || 0) : '-'}</span>{' '}
|
||||
<span className="opacity-70">• Renown:</span> <span className="font-semibold">{currentPlayer ? (currentPlayer.renown || 'None') : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Bar: Save + Logout */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div className="text-sm opacity-80">
|
||||
{shopAuthed ? (
|
||||
<>
|
||||
<span className="mr-2">
|
||||
Logged in as <span className="font-semibold">{shopAuthed}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="italic opacity-70">Not logged into shop</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveLocal}
|
||||
className={`px-3 py-1.5 rounded-lg border border-white/10 text-sm ${shopAuthed ? 'bg-emerald-600 hover:bg-emerald-500' : 'bg-emerald-900/40 cursor-not-allowed'}`}
|
||||
disabled={!shopAuthed}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={logoutShop}
|
||||
className="px-3 py-1.5 rounded-lg bg-rose-600 hover:bg-rose-500 border border-white/10 text-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
{saveMsg && (
|
||||
<span className="ml-2 text-xs px-2 py-1 rounded bg-white/10 border border-white/10">
|
||||
{saveMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Character Info Section */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="col-span-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Character Info</h2>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Character Name</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={charName}
|
||||
onChange={(e) => setCharName(e.target.value)}
|
||||
disabled={!shopAuthed || gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Player Name</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
disabled={!shopAuthed || gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Chapter</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={chapter}
|
||||
onChange={(e) => setChapter(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Chapter Demeanour</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={demeanour}
|
||||
onChange={(e) => setDemeanour(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Speciality</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={speciality}
|
||||
onChange={(e) => setSpeciality(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Rank</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={rank}
|
||||
onChange={(e) => setRank(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Power Armour History</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={powerArmour}
|
||||
onChange={(e) => setPowerArmour(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Description</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Past Event</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={pastEvent}
|
||||
onChange={(e) => setPastEvent(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Personal Demeanour</label>
|
||||
<input
|
||||
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1"
|
||||
value={personalDemeanour}
|
||||
onChange={(e) => setPersonalDemeanour(e.target.value)}
|
||||
disabled={gmOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex justify-end mt-4">
|
||||
<button
|
||||
onClick={() => setGmOpen(!gmOpen)}
|
||||
className="px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 border border-white/10 text-sm"
|
||||
>
|
||||
{gmOpen ? 'Close GM Panel' : 'Open GM Panel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requisition Points Display */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-sm font-medium">Requisition Points</div>
|
||||
<div className="text-xs opacity-70">Current RP: <span className="font-semibold">{currentPlayer ? currentPlayer.rp : 'N/A'}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Characteristics */}
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Characteristics</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-9 gap-2">
|
||||
{CHARACTERISTICS.map(c => (
|
||||
<div key={c.key} className="flex flex-col items-center">
|
||||
<label className="text-xs opacity-70 mb-1">{c.label}</label>
|
||||
<input type="number" className="w-16 text-center text-lg rounded border border-white/10 bg-white/10 px-2 py-1" value={characteristics[c.key]} onChange={e=>handleCharChange(c.key, parseInt(e.target.value||'0'))} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Skills</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{SKILLS.map(skill => (
|
||||
<div key={skill} className="flex items-center gap-2">
|
||||
<span className="text-xs min-w-[120px]">{skill}</span>
|
||||
<input type="checkbox" checked={!!skills[skill]?.trained} onChange={e => handleSkillChange(skill, 'trained', e.target.checked)} />
|
||||
<input type="checkbox" checked={!!skills[skill]?.plus10} onChange={e => handleSkillChange(skill, 'plus10', e.target.checked)} />
|
||||
<input type="checkbox" checked={!!skills[skill]?.plus20} onChange={e => handleSkillChange(skill, 'plus20', e.target.checked)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Space Marine Abilities */}
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Space Marine Abilities</div>
|
||||
<div className="text-xs opacity-80">
|
||||
Secondary Heart, Larraman’s Organ, Catalepsean Node, Preomnor, Omophagea, Multi-lung, Sus-an Membrane, Oolitic Kidney, Neuroglottis, Mucranoid, Betcher’s Gland, Progenoid Glands, Melanochrome, Occulobe, Lyman’s Ear
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gear Section */}
|
||||
<div className="bg-white/5 rounded-xl p-6 border border-white/10">
|
||||
<div className="font-semibold text-lg mb-4">Gear</div>
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-4">
|
||||
{gear.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-6">
|
||||
<input
|
||||
className="rounded-xl border border-white/10 bg-white/10 px-4 py-3 flex-1"
|
||||
value={item.name}
|
||||
disabled={!isGMOrShopAuthed()}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded-xl border border-white/10 bg-white/10 px-4 py-3 w-24"
|
||||
value={item.qty || 1}
|
||||
disabled={!isGMOrShopAuthed()}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-white/10 bg-white/10 px-4 py-3 flex-1"
|
||||
placeholder="Note"
|
||||
value={item.note || ''}
|
||||
onChange={(e) => updateGear(item.id, { note: e.target.value })}
|
||||
disabled={!isGMOrShopAuthed()}
|
||||
/>
|
||||
<button
|
||||
className="rounded-xl px-4 py-3 bg-rose-600 hover:bg-rose-500"
|
||||
onClick={() => deleteGear(item.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{isGMLoggedIn() && (
|
||||
<button
|
||||
className="rounded-xl px-4 py-3 bg-blue-600 hover:bg-blue-500"
|
||||
onClick={() => addGearItem('')}
|
||||
>
|
||||
Add gear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weapons & Armour */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Weapons</div>
|
||||
{[0,1,2].map(idx => (
|
||||
<div key={idx} className="mb-2 border-b border-white/10 pb-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-1">
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Name" value={weapons[idx]?.name||''} onChange={e=>handleWeaponChange(idx,'name',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Class" value={weapons[idx]?.class||''} onChange={e=>handleWeaponChange(idx,'class',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Damage" value={weapons[idx]?.damage||''} onChange={e=>handleWeaponChange(idx,'damage',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Type" value={weapons[idx]?.type||''} onChange={e=>handleWeaponChange(idx,'type',e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-1">
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Pen" value={weapons[idx]?.pen||''} onChange={e=>handleWeaponChange(idx,'pen',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Range" value={weapons[idx]?.range||''} onChange={e=>handleWeaponChange(idx,'range',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="RoF" value={weapons[idx]?.rof||''} onChange={e=>handleWeaponChange(idx,'rof',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Clip" value={weapons[idx]?.clip||''} onChange={e=>handleWeaponChange(idx,'clip',e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-1">
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Rld" value={weapons[idx]?.rld||''} onChange={e=>handleWeaponChange(idx,'rld',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Special Rules" value={weapons[idx]?.special||''} onChange={e=>handleWeaponChange(idx,'special',e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Armour</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Head" value={armour.head} onChange={e=>handleArmourChange('head',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Body" value={armour.body} onChange={e=>handleArmourChange('body',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Right Arm" value={armour.ra} onChange={e=>handleArmourChange('ra',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Left Arm" value={armour.la} onChange={e=>handleArmourChange('la',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Right Leg" value={armour.rl} onChange={e=>handleArmourChange('rl',e.target.value)} />
|
||||
<input className="rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Left Leg" value={armour.ll} onChange={e=>handleArmourChange('ll',e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" placeholder="Additions/Notes" value={armour.additions} onChange={e=>handleArmourChange('additions',e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Talents, Psychic Powers */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Talents & Traits</div>
|
||||
<textarea className="w-full h-32 rounded border border-white/10 bg-white/10 px-2 py-1" value={talents} onChange={e=>setTalents(e.target.value)} />
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Psychic Powers</div>
|
||||
<textarea className="w-full h-32 rounded border border-white/10 bg-white/10 px-2 py-1" value={psychic} onChange={e=>setPsychic(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wounds, Insanity, Movement, Fate, Corruption, Renown, XP */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-2 bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Wounds</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Total" value={wounds.total} onChange={e=>setWounds(w=>({...w,total:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Current" value={wounds.current} onChange={e=>setWounds(w=>({...w,current:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" placeholder="Fatigue" value={wounds.fatigue} onChange={e=>setWounds(w=>({...w,fatigue:parseInt(e.target.value||'0')}))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Insanity</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Current" value={insanity.current} onChange={e=>setInsanity(i=>({...i,current:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Battle Fatigue" value={insanity.battleFatigue} onChange={e=>setInsanity(i=>({...i,battleFatigue:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" placeholder="Primarch's Curse" value={insanity.primarchsCurse} onChange={e=>setInsanity(i=>({...i,primarchsCurse:parseInt(e.target.value||'0')}))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Movement</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Half" value={movement.half} onChange={e=>setMovement(m=>({...m,half:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Charge" value={movement.charge} onChange={e=>setMovement(m=>({...m,charge:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" placeholder="Full" value={movement.full} onChange={e=>setMovement(m=>({...m,full:parseInt(e.target.value||'0')}))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Fate</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" placeholder="Total" value={fate.total} onChange={e=>setFate(f=>({...f,total:parseInt(e.target.value||'0')}))} />
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" placeholder="Current" value={fate.current} onChange={e=>setFate(f=>({...f,current:parseInt(e.target.value||'0')}))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Corruption</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" placeholder="Current" value={corruption} onChange={e=>setCorruption(parseInt(e.target.value||'0'))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Renown</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" value={renown} onChange={e=>setRenown(e.target.value)} />
|
||||
<label className="text-xs uppercase opacity-70">XP</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" value={xp} onChange={e=>setXp(parseInt(e.target.value||'0'))} />
|
||||
<label className="text-xs uppercase opacity-70">XP Spent</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" value={xpSpent} onChange={e=>setXpSpent(parseInt(e.target.value||'0'))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Notes</div>
|
||||
<textarea className="w-full h-24 rounded border border-white/10 bg-white/10 px-2 py-1" value={notes} onChange={e=>setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerTab
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import axios from 'axios';
|
||||
|
||||
function safeGet(key) {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !('localStorage' in window)) return null
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch { return null }
|
||||
}
|
||||
function safeSet(key, val) { try { if (typeof window !== 'undefined' && 'localStorage' in window) window.localStorage.setItem(key, JSON.stringify(val)) } catch {} }
|
||||
function safeRemove(key) { try { if (typeof window !== 'undefined' && 'localStorage' in window) window.localStorage.removeItem(key) } catch {} }
|
||||
|
||||
async function sha256Hex(str) {
|
||||
try {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
|
||||
const arr = Array.from(new Uint8Array(buf))
|
||||
return arr.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1'
|
||||
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer'
|
||||
|
||||
const GM_PASSWORD = 'bongo'
|
||||
|
||||
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero']
|
||||
function normalizeRank(r) {
|
||||
const s = String(r||'').trim()
|
||||
const found = RANK_ORDER.find(x=>x.toLowerCase()===s.toLowerCase())
|
||||
return found || (s? s : 'None')
|
||||
}
|
||||
function rankIndex(r) { const idx = RANK_ORDER.indexOf(normalizeRank(r)); return idx<0?0:idx }
|
||||
function renownClass(r) {
|
||||
const rr = normalizeRank(r)
|
||||
if (rr==='Respected') return 'bg-emerald-700'
|
||||
if (rr==='Distinguished') return 'bg-indigo-700'
|
||||
if (rr==='Famed') return 'bg-purple-700'
|
||||
if (rr==='Hero') return 'bg-rose-700'
|
||||
return 'bg-slate-700'
|
||||
}
|
||||
|
||||
function slugify(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'') }
|
||||
|
||||
function normalizeItem(it) {
|
||||
const name = String(it.name||'').trim()
|
||||
if (!name) return null
|
||||
const id = String(it.id||slugify(name))
|
||||
const category = String(it.category||'Gear').trim()
|
||||
const req = Math.max(0, Number.isFinite(+it.req) ? +it.req : (Number.isFinite(+it.cost)? +it.cost : 0))
|
||||
const cost = req
|
||||
const desc = String(it.desc||'').trim()
|
||||
const renown = normalizeRank(it.renown||it.Renown)
|
||||
return { id, name, category, cost, req, renown, desc }
|
||||
}
|
||||
|
||||
export default function RequisitionShop() {
|
||||
const [items, setItems] = useState([])
|
||||
const [players, setPlayers] = useState(() => safeGet(STORAGE_SHOP_PLAYERS) || []);
|
||||
const [authedPlayer, setAuthedPlayer] = useState(() => safeGet(STORAGE_SHOP_AUTHED) || '');
|
||||
const [gmOpen, setGmOpen] = useState(false)
|
||||
const [gmPassInput, setGmPassInput] = useState('')
|
||||
const [gmUnlocked, setGmUnlocked] = useState(false)
|
||||
|
||||
const [playerName, setPlayerName] = useState('')
|
||||
const [playerPw, setPlayerPw] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const categories = useMemo(()=>{
|
||||
const set = new Set()
|
||||
for (const it of items) { if (it && it.category) set.add(String(it.category)) }
|
||||
return ['All', ...Array.from(set).sort((a,b)=>String(a).localeCompare(String(b)))]
|
||||
},[items])
|
||||
const [categoryFilter, setCategoryFilter] = useState('All')
|
||||
|
||||
// Items are sourced from a single JSON file; disable local persistence
|
||||
// useEffect(()=>{ safeSet(STORAGE_SHOP_ITEMS, items) },[items])
|
||||
useEffect(()=>{ safeSet(STORAGE_SHOP_PLAYERS, players) },[players])
|
||||
useEffect(()=>{
|
||||
// Always load from the static JSON on startup
|
||||
;(async()=>{
|
||||
try {
|
||||
const res = await fetch('/deathwatch-armoury.json', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const arr = await res.json()
|
||||
const next = Array.isArray(arr) ? arr.map(normalizeItem).filter(Boolean) : []
|
||||
if (next.length>0) {
|
||||
setItems(next)
|
||||
}
|
||||
} catch {}
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[])
|
||||
useEffect(()=>{
|
||||
// Restore authed player on mount
|
||||
const ap = safeGet(STORAGE_SHOP_AUTHED)
|
||||
if (ap && typeof ap === 'string') setAuthedPlayer(ap)
|
||||
},[])
|
||||
|
||||
const currentPlayer = useMemo(()=> players.find(p=>p.name===authedPlayer) || null,[players, authedPlayer])
|
||||
const filteredItems = useMemo(()=>{
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter(i => {
|
||||
const inQuery = !q || i.name.toLowerCase().includes(q) || (i.category||'').toLowerCase().includes(q)
|
||||
const inCat = categoryFilter==='All' || String(i.category||'').toLowerCase()===String(categoryFilter).toLowerCase()
|
||||
return inQuery && inCat
|
||||
})
|
||||
},[items, search, categoryFilter])
|
||||
|
||||
// Define flash function
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
function flash(msg) {
|
||||
setSaveMsg(msg);
|
||||
setTimeout(() => setSaveMsg(''), 2000);
|
||||
}
|
||||
|
||||
// Ensure GM Panel login functionality works correctly
|
||||
async function handleGmUnlock() {
|
||||
if (gmPassInput === GM_PASSWORD) {
|
||||
setGmUnlocked(true);
|
||||
flash('GM Panel unlocked');
|
||||
} else {
|
||||
flash('Incorrect GM password');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure player login functionality is consistent
|
||||
async function handlePlayerLogin() {
|
||||
const player = players.find(p => p.name === playerName);
|
||||
if (player && player.pw === playerPw) {
|
||||
setAuthedPlayer(playerName);
|
||||
flash('Player logged in');
|
||||
} else {
|
||||
flash('Invalid player credentials');
|
||||
}
|
||||
}
|
||||
function handlePlayerLogout() {
|
||||
safeRemove(STORAGE_SHOP_AUTHED);
|
||||
setAuthedPlayer('');
|
||||
flash('Player logged out');
|
||||
}
|
||||
|
||||
// Updated purchaseItem to subtract requisition points from the player
|
||||
async function purchaseItem(item) {
|
||||
if (!currentPlayer) return;
|
||||
const updatedPlayer = {
|
||||
...currentPlayer,
|
||||
rp: (currentPlayer.rp || 0) - (item.cost || 0),
|
||||
inventory: [...(currentPlayer.inventory || []), item],
|
||||
transactions: [...(currentPlayer.transactions || []), { item, date: new Date() }],
|
||||
};
|
||||
try {
|
||||
await axios.put(`/api/players/${updatedPlayer.name}`, updatedPlayer);
|
||||
setPlayers(prevPlayers => {
|
||||
const index = prevPlayers.findIndex(p => p.name === updatedPlayer.name);
|
||||
if (index !== -1) {
|
||||
const newPlayers = [...prevPlayers];
|
||||
newPlayers[index] = updatedPlayer;
|
||||
return newPlayers;
|
||||
}
|
||||
return [...prevPlayers, updatedPlayer];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update player:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function addPlayer(name, rp, pw) {
|
||||
const nm = String(name||'').trim()
|
||||
if (!nm) return
|
||||
const h = await sha256Hex(String(pw||'').trim())
|
||||
setPlayers(prev => {
|
||||
const others = prev.filter(x=>x.name!==nm)
|
||||
return [...others, { name:nm, rp: Math.max(0, Number.isFinite(+rp)? +rp : 0), pwHash: h, inventory: [], renown: 'None' }].sort((a,b)=>a.name.localeCompare(b.name))
|
||||
})
|
||||
}
|
||||
function setPlayerRP(name, rp) {
|
||||
const nm = String(name||'').trim()
|
||||
setPlayers(prev => prev.map(p => p.name===nm ? { ...p, rp: Math.max(0, Number.isFinite(+rp)? +rp : 0) } : p))
|
||||
}
|
||||
function setPlayerRenown(name, renown) {
|
||||
const nm = String(name||'').trim()
|
||||
setPlayers(prev => prev.map(p => p.name===nm ? { ...p, renown: normalizeRank(renown) } : p))
|
||||
}
|
||||
async function resetPlayerPW(name, pw) {
|
||||
const nm = String(name || '').trim();
|
||||
if (!nm) {
|
||||
flash('Player name is required');
|
||||
return;
|
||||
}
|
||||
const h = await sha256Hex(String(pw || '').trim());
|
||||
setPlayers(prev => prev.map(p => p.name === nm ? { ...p, pw: pw, pwHash: h } : p));
|
||||
flash(`Password reset for ${nm}`);
|
||||
}
|
||||
function deletePlayer(name) { setPlayers(prev => prev.filter(p => p.name!==name)) }
|
||||
|
||||
function setItemMeta(id, req, renown) {
|
||||
setItems(prev => prev.map(it => it.id===id ? { ...it, req: Math.max(0, Number.isFinite(+req)? +req : (it.req||0)), cost: Math.max(0, Number.isFinite(+req)? +req : (it.cost||0)), renown: normalizeRank(renown||it.renown) } : it))
|
||||
}
|
||||
|
||||
// Display saveMsg in the UI
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/5 backdrop-blur border border-white/10 p-5 shadow-xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">Requisition Shop</div>
|
||||
<button
|
||||
onClick={() => setGmOpen(!gmOpen)}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
{gmOpen ? 'Close GM Panel' : 'Open GM Panel'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<div className="text-xs px-2 py-1 rounded bg-white/10 border border-white/10">
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-xs uppercase opacity-70">Player Login</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<select className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" value={playerName} onChange={e=>setPlayerName(e.target.value)}>
|
||||
<option value="">Select player</option>
|
||||
{players.map(p => (<option key={p.name} value={p.name}>{p.name}</option>))}
|
||||
</select>
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Password" value={playerPw} onChange={e=>setPlayerPw(e.target.value)} />
|
||||
{!authedPlayer ? (
|
||||
<button onClick={handlePlayerLogin} className="rounded-xl px-3 py-2 bg-blue-600 hover:bg-blue-500">Login</button>
|
||||
) : (
|
||||
<button onClick={handlePlayerLogout} className="rounded-xl px-3 py-2 bg-slate-700 hover:bg-slate-600">Logout ({authedPlayer})</button>
|
||||
)}
|
||||
<div className="rounded-xl bg-white/10 px-3 py-2 text-sm">
|
||||
RP: <span className="font-semibold">{currentPlayer ? (currentPlayer.rp||0) : '-'}</span>{' '}
|
||||
<span className="opacity-70">• Renown:</span> <span className="font-semibold">{currentPlayer ? (currentPlayer.renown||'None') : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">Items</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="rounded-xl border border-white/10 bg-white/10 px-2 py-1.5 text-sm" value={categoryFilter} onChange={e=>setCategoryFilter(e.target.value)}>
|
||||
{categories.map(c => (<option key={c} value={c}>{c}</option>))}
|
||||
</select>
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-1.5 text-sm" placeholder="Search..." value={search} onChange={e=>setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-2">
|
||||
{filteredItems.map(it => (
|
||||
<div key={it.id} className="rounded-lg border border-white/10 bg-white/5 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{it.name}</div>
|
||||
<div className="text-xs opacity-70 truncate">{it.category}</div>
|
||||
</div>
|
||||
<div className="text-xs shrink-0">Req <span className="font-semibold">{it.req ?? it.cost ?? 0}</span></div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${renownClass(it.renown)}`}>{it.renown || 'None'}</span>
|
||||
{gmUnlocked && (
|
||||
<GmEditItemMeta item={it} onSet={setItemMeta} />
|
||||
)}
|
||||
<button
|
||||
onClick={()=>purchaseItem(it)}
|
||||
disabled={!currentPlayer || (currentPlayer?.rp||0) < (it.cost||0) || rankIndex((currentPlayer?.renown)||'None') < rankIndex((it.renown)||'None')}
|
||||
className={`text-sm rounded-lg px-3 py-1.5 ${(!currentPlayer || (currentPlayer?.rp||0) < (it.cost||0) || rankIndex((currentPlayer?.renown)||'None') < rankIndex((it.renown)||'None')) ? 'bg-slate-700 opacity-60' : 'bg-emerald-600 hover:bg-emerald-500'}`}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredItems.length===0 && (<div className="text-xs opacity-70">No items</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-sm font-medium">Inventory {currentPlayer ? `• ${currentPlayer.name}` : ''}</div>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto pr-2">
|
||||
{currentPlayer && currentPlayer.inventory && currentPlayer.inventory.length > 0 ? (
|
||||
currentPlayer.inventory.map((inv, i) => (
|
||||
<div key={i} className="text-sm">{inv.name} <span className="opacity-70 text-xs">({inv.cost})</span></div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs opacity-70">No purchases</div>
|
||||
)}
|
||||
{currentPlayer && currentPlayer.transactions && currentPlayer.transactions.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-semibold">Transaction History:</div>
|
||||
{currentPlayer.transactions.map((txn, i) => (
|
||||
<div key={i} className="text-xs">{txn.item.name} - {txn.item.cost} Req - {new Date(txn.date).toLocaleString()}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gmOpen && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-900/20 p-3 space-y-3">
|
||||
<div className="text-sm font-semibold text-amber-200">GM Controls</div>
|
||||
{!gmUnlocked ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Enter GM password" value={gmPassInput} onChange={e=>setGmPassInput(e.target.value)} />
|
||||
<button onClick={handleGmUnlock} className="rounded-xl px-3 py-2 bg-amber-600 hover:bg-amber-500">Unlock</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-sm font-medium">Players</div>
|
||||
<GmAddPlayer onAdd={addPlayer} />
|
||||
<div className="space-y-2 max-h-44 overflow-y-auto pr-2">
|
||||
{players.map(p=> (
|
||||
<div key={p.name} className="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center rounded-lg border border-white/10 bg-white/5 px-3 py-2">
|
||||
<div className="text-sm font-semibold">{p.name}</div>
|
||||
<div className="text-sm">RP: {p.rp||0}</div>
|
||||
<GmSetRP name={p.name} onSet={setPlayerRP} />
|
||||
<GmSetRenown name={p.name} value={p.renown||'None'} onSet={setPlayerRenown} />
|
||||
<GmResetPW name={p.name} onReset={resetPlayerPW} />
|
||||
<button onClick={()=>deletePlayer(p.name)} className="text-xs px-2 py-1 rounded bg-rose-600 hover:bg-rose-500">Delete</button>
|
||||
</div>
|
||||
))}
|
||||
{players.length===0 && (<div className="text-xs opacity-70">No players</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-sm font-medium">GM Settings</div>
|
||||
<GmChangePassword onSet={(pw)=>{ setGmPassInput(''); /* placeholder: fixed bongo; not persisted */ }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GmAddPlayer({ onAdd }) {
|
||||
const [name, setName] = useState('')
|
||||
const [rp, setRp] = useState(10)
|
||||
const [pw, setPw] = useState('')
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" placeholder="Player name" value={name} onChange={e=>setName(e.target.value)} />
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="number" min={0} value={rp} onChange={e=>setRp(parseInt(e.target.value||'0'))} />
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-3 py-2" type="password" placeholder="Password" value={pw} onChange={e=>setPw(e.target.value)} />
|
||||
<button onClick={()=>{ onAdd(name, rp, pw); setName(''); setRp(10); setPw('') }} className="rounded-xl px-3 py-2 bg-amber-600 hover:bg-amber-500">Add/Update</button>
|
||||
<div className="self-center text-xs opacity-70">Add or overwrite by name</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function GmSetRP({ name, onSet }) {
|
||||
const [rp, setRp] = useState('')
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" type="number" placeholder="RP" value={rp} onChange={e=>setRp(e.target.value)} />
|
||||
<button onClick={()=>{ onSet(name, parseInt(rp||'0')); setRp('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set RP</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function GmSetRenown({ name, value, onSet }) {
|
||||
const [renown, setRenown] = useState(value)
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<select className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm w-20" value={renown} onChange={e=>setRenown(e.target.value)}>
|
||||
{RANK_ORDER.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button onClick={()=>{ onSet(name, renown); setRenown(value) }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set Renown</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function GmResetPW({ name, onReset }) {
|
||||
const [pw, setPw] = useState('')
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1 text-sm" type="password" placeholder="New PW" value={pw} onChange={e=>setPw(e.target.value)} />
|
||||
<button onClick={()=>{ onReset(name, pw); setPw('') }} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Reset PW</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GmEditItemMeta({ item, onSet }) {
|
||||
const [req, setReq] = useState(item.req ?? item.cost ?? 0)
|
||||
const [renown, setRenown] = useState(item.renown || 'None')
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input className="w-16 rounded border border-white/10 bg-white/10 px-2 py-1 text-xs" type="number" min={0} value={req} onChange={e=>setReq(e.target.value)} />
|
||||
<select className="rounded border border-white/10 bg-white/10 px-2 py-1 text-xs" value={renown} onChange={e=>setRenown(e.target.value)}>
|
||||
{RANK_ORDER.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button onClick={()=>onSet(item.id, parseInt(req||'0'), renown)} className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">Set</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GmChangePassword({ onSet }){
|
||||
const [pw, setPw] = useState('')
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input className="rounded-xl border border-white/10 bg-white/10 px-2 py-1" type="password" placeholder="New GM password (not persisted)" value={pw} onChange={e=>setPw(e.target.value)} />
|
||||
<button onClick={()=>{ onSet(pw); setPw('') }} className="px-2 py-1 rounded bg-amber-600 hover:bg-amber-500">Set Password</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply min-h-screen text-slate-100 p-4 sm:p-6 md:p-10;
|
||||
background: linear-gradient(to bottom right, rgb(12, 21, 37), rgb(19, 32, 58));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply min-h-screen flex flex-col;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.app-container {
|
||||
@apply mx-auto w-full max-w-6xl space-y-4 sm:space-y-6;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-3xl md:text-4xl font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl bg-white/5 backdrop-blur border border-white/10 p-4 sm:p-5 shadow-xl;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply text-xs px-2 py-1 rounded font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-emerald-600;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-amber-500;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-rose-600;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2;
|
||||
}
|
||||
|
||||
.textarea-field {
|
||||
@apply w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2;
|
||||
}
|
||||
|
||||
.select-field {
|
||||
@apply input-field appearance-none pr-8;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6;
|
||||
}
|
||||
|
||||
.result-group {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-xs uppercase tracking-wide text-slate-400;
|
||||
}
|
||||
|
||||
.roll-stat {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.roll-card {
|
||||
@apply rounded-xl border border-white/10 bg-black/20 p-3;
|
||||
}
|
||||
|
||||
.roll-type {
|
||||
@apply text-xs text-slate-400;
|
||||
}
|
||||
|
||||
.roll-value {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
.roll-info {
|
||||
@apply text-xs text-slate-500 mt-1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-2 w-full bg-white/10 rounded overflow-hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import RequisitionShop from '../components/RequisitionShop';
|
||||
|
||||
describe('RequisitionShop Component', () => {
|
||||
test('renders the shop component', () => {
|
||||
render(<RequisitionShop />);
|
||||
const shopTitle = screen.getByText(/Requisition Shop/i);
|
||||
expect(shopTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('purchaseItem function subtracts requisition points', async () => {
|
||||
render(<RequisitionShop />);
|
||||
|
||||
// Mock player data
|
||||
const mockPlayer = { name: 'TestPlayer', rp: 100, renown: 'None' };
|
||||
const mockItem = { id: 'item1', name: 'TestItem', req: 50, renown: 'None' };
|
||||
|
||||
// Simulate purchasing an item
|
||||
mockPlayer.rp -= mockItem.req;
|
||||
expect(mockPlayer.rp).toBe(50);
|
||||
});
|
||||
|
||||
test('addPlayer function adds a new player', async () => {
|
||||
render(<RequisitionShop />);
|
||||
|
||||
// Mock player data
|
||||
const mockPlayer = { name: 'NewPlayer', rp: 100, renown: 'None' };
|
||||
|
||||
// Simulate adding a player
|
||||
const players = [mockPlayer];
|
||||
expect(players).toContainEqual(mockPlayer);
|
||||
});
|
||||
|
||||
test('handlePlayerLogin logs in a player', async () => {
|
||||
render(<RequisitionShop />);
|
||||
|
||||
// Mock player data
|
||||
const mockPlayer = { name: 'LoginPlayer', rp: 100, renown: 'None' };
|
||||
|
||||
// Simulate player login
|
||||
const loggedInPlayer = mockPlayer.name;
|
||||
expect(loggedInPlayer).toBe('LoginPlayer');
|
||||
});
|
||||
|
||||
test('handlePlayerLogout logs out a player', async () => {
|
||||
render(<RequisitionShop />);
|
||||
|
||||
// Simulate player logout
|
||||
await fireEvent.click(screen.getByText(/Logout/i));
|
||||
const loggedOutPlayer = screen.queryByText(/LogoutPlayer/i);
|
||||
expect(loggedOutPlayer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user