Initial commit

This commit is contained in:
Alex Polo
2025-08-14 23:55:55 +02:00
commit 125d4e2f8b
59 changed files with 26984 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/* Add any app-specific styles here */
+24
View File
@@ -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;
+8
View File
@@ -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
+637
View File
@@ -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
+408
View File
@@ -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>
)
}
+97
View File
@@ -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;
}
}
+17
View File
@@ -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();
+1
View File
@@ -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

+13
View File
@@ -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;
+5
View File
@@ -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';
+53
View File
@@ -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();
});
});