more slight changes.
This commit is contained in:
parent
bfb0f20a25
commit
962c0bd911
226
src/App.js
226
src/App.js
@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'; // Added useRef
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
||||
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore'; // Removed where as it's not used
|
||||
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon } from 'lucide-react';
|
||||
import { getFirestore, doc, setDoc, addDoc, getDoc, getDocs, collection, onSnapshot, updateDoc, deleteDoc, query, writeBatch } from 'firebase/firestore';
|
||||
import { ArrowLeft, PlusCircle, Users, Swords, Shield, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, ChevronDown, ChevronUp, UserCheck, UserX, HeartCrack, HeartPulse, Zap, Share2, Copy as CopyIcon, Image as ImageIcon, EyeOff } from 'lucide-react';
|
||||
|
||||
// --- Firebase Configuration ---
|
||||
const firebaseConfig = {
|
||||
@ -24,7 +24,6 @@ const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(`CRITICAL: Missing Firebase config values from environment variables: ${missingKeys.join(', ')}`);
|
||||
console.error("Firebase cannot be initialized. Please ensure all REACT_APP_FIREBASE_... variables are set in your .env.local file and accessible during the build.");
|
||||
// Fallback: Render a message or allow app to break if Firebase is critical
|
||||
} else {
|
||||
try {
|
||||
app = initializeApp(firebaseConfig);
|
||||
@ -32,11 +31,9 @@ if (missingKeys.length > 0) {
|
||||
auth = getAuth(app);
|
||||
} catch (error) {
|
||||
console.error("Error initializing Firebase:", error);
|
||||
// Handle initialization error, perhaps by setting db and auth to null or showing an error UI
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Firestore Paths ---
|
||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
||||
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
||||
@ -60,10 +57,10 @@ function App() {
|
||||
const [directDisplayParams, setDirectDisplayParams] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) { // Check if Firebase auth was initialized
|
||||
if (!auth) {
|
||||
setError("Firebase Auth not initialized. Check your Firebase configuration.");
|
||||
setIsLoading(false);
|
||||
setIsAuthReady(false); // Explicitly set auth not ready
|
||||
setIsAuthReady(false);
|
||||
return;
|
||||
}
|
||||
const handleHashChange = () => {
|
||||
@ -109,7 +106,7 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!db || !auth) { // If Firebase failed to init
|
||||
if (!db || !auth) {
|
||||
return (
|
||||
<div className="min-h-screen bg-red-900 text-white flex flex-col items-center justify-center p-4">
|
||||
<h1 className="text-3xl font-bold">Configuration Error</h1>
|
||||
@ -132,48 +129,37 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const goToAdminView = () => {
|
||||
setViewMode('admin');
|
||||
setDirectDisplayParams(null);
|
||||
window.location.hash = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-800 text-slate-100 font-sans">
|
||||
{!directDisplayParams && (
|
||||
<header className="bg-slate-900 p-4 shadow-lg">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
|
||||
<h1
|
||||
className="text-3xl font-bold text-sky-400 cursor-pointer hover:text-sky-300 transition-colors"
|
||||
onClick={goToAdminView}
|
||||
title="Go to Admin View"
|
||||
>
|
||||
TTRPG Initiative Tracker
|
||||
</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
|
||||
{/* Show Admin View button only if not in display mode */}
|
||||
{viewMode !== 'display' && (
|
||||
<button
|
||||
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
>
|
||||
Admin View
|
||||
</button>
|
||||
)}
|
||||
{/* Show Player Display button only if not in admin mode (or always if header is visible and it's the only option left) */}
|
||||
{viewMode !== 'admin' && (
|
||||
<button
|
||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
>
|
||||
Player Display
|
||||
</button>
|
||||
)}
|
||||
{/* Simpler toggle: always show both if header is visible and style the active one */}
|
||||
{/* The above logic is slightly off for two buttons always present. Let's fix: */}
|
||||
{/* Corrected Header Buttons for Toggling */}
|
||||
<button
|
||||
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'admin' ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
// Hide if current view is display, but allow access from admin
|
||||
style={viewMode === 'display' ? { display: 'none' } : {}}
|
||||
>
|
||||
Admin View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
||||
onClick={() => {
|
||||
if (viewMode === 'display') {
|
||||
goToAdminView();
|
||||
} else {
|
||||
setViewMode('display');
|
||||
}
|
||||
}}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${viewMode === 'display' ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
>
|
||||
Player Display
|
||||
{viewMode === 'display' ? 'Admin Controls' : 'Player Display'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,11 +172,11 @@ function App() {
|
||||
)}
|
||||
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
|
||||
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
|
||||
{!isAuthReady && !error && <p>Authenticating...</p>} {/* Show auth message only if no other error */}
|
||||
{!isAuthReady && !error && <p>Authenticating...</p>}
|
||||
</main>
|
||||
{!directDisplayParams && (
|
||||
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
|
||||
TTRPG Initiative Tracker v0.1.13
|
||||
TTRPG Initiative Tracker v0.1.15
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
@ -205,7 +191,7 @@ function AdminView({ userId }) {
|
||||
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!db) return; // Guard against Firebase not initialized
|
||||
if (!db) return;
|
||||
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
|
||||
const q = query(campaignsCollectionRef);
|
||||
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
|
||||
@ -239,12 +225,16 @@ function AdminView({ userId }) {
|
||||
}, [initialActiveInfo, campaigns, selectedCampaignId]);
|
||||
|
||||
|
||||
const handleCreateCampaign = async (name) => {
|
||||
const handleCreateCampaign = async (name, backgroundUrl) => {
|
||||
if (!db || !name.trim()) return;
|
||||
const newCampaignId = generateId();
|
||||
try {
|
||||
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
|
||||
name: name.trim(), ownerId: userId, createdAt: new Date().toISOString(), players: [],
|
||||
name: name.trim(),
|
||||
playerDisplayBackgroundUrl: backgroundUrl.trim() || '',
|
||||
ownerId: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
players: [],
|
||||
});
|
||||
setShowCreateCampaignModal(false);
|
||||
setSelectedCampaignId(newCampaignId);
|
||||
@ -292,6 +282,7 @@ function AdminView({ userId }) {
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
|
||||
<p className="text-xs text-slate-400">ID: {campaign.id}</p>
|
||||
{campaign.playerDisplayBackgroundUrl && <ImageIcon size={14} className="inline-block mr-1 text-slate-400" title="Has custom background"/>}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -324,12 +315,23 @@ function AdminView({ userId }) {
|
||||
|
||||
function CreateCampaignForm({ onCreate, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const [backgroundUrl, setBackgroundUrl] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onCreate(name, backgroundUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onCreate(name); }} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="campaignName" className="block text-sm font-medium text-slate-300">Campaign Name</label>
|
||||
<input type="text" id="campaignName" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backgroundUrl" className="block text-sm font-medium text-slate-300">Player Display Background URL (Optional)</label>
|
||||
<input type="url" id="backgroundUrl" value={backgroundUrl} onChange={(e) => setBackgroundUrl(e.target.value)} placeholder="https://example.com/image.jpg" className="mt-1 block w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white" />
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm font-medium text-slate-300 bg-slate-600 hover:bg-slate-500 rounded-md transition-colors">Cancel</button>
|
||||
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 rounded-md transition-colors">Create</button>
|
||||
@ -470,9 +472,27 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true });
|
||||
console.log("Encounter set as active for DM's main display!");
|
||||
} catch (err) { console.error("Error setting active display:", err); }
|
||||
const currentActiveCampaign = activeDisplayInfo?.activeCampaignId;
|
||||
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
||||
|
||||
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
||||
// Currently active, so toggle off
|
||||
await updateDoc(doc(db, ACTIVE_DISPLAY_DOC), {
|
||||
activeCampaignId: null,
|
||||
activeEncounterId: null,
|
||||
});
|
||||
console.log("Encounter display toggled OFF for DM.");
|
||||
} else {
|
||||
// Not active or different encounter, so set this one as active
|
||||
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
|
||||
activeCampaignId: campaignId,
|
||||
activeEncounterId: encounterId,
|
||||
}, { merge: true });
|
||||
console.log("Encounter set as active for DM's main display!");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error toggling active display:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = (encounterId) => {
|
||||
@ -504,7 +524,13 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
||||
{isDmActiveDisplay && <span className="text-xs text-green-400 font-semibold block mt-1">LIVE ON DM DISPLAY</span>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)} className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-green-500 hover:bg-green-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`} title="Set as DM's Active Display"><Eye size={18} /></button>
|
||||
<button
|
||||
onClick={() => handleSetEncounterAsActiveDisplay(encounter.id)}
|
||||
className={`p-1 rounded transition-colors ${isDmActiveDisplay ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-teal-400 hover:text-teal-300 bg-slate-600 hover:bg-slate-500'}`}
|
||||
title={isDmActiveDisplay ? "Deactivate DM Display" : "Set as DM's Active Display"}
|
||||
>
|
||||
{isDmActiveDisplay ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
<button onClick={() => handleCopyToClipboard(encounter.id)} className="p-1 rounded transition-colors text-sky-400 hover:text-sky-300 bg-slate-600 hover:bg-slate-500" title="Copy Share Link for Players"><Share2 size={18} /></button>
|
||||
{copiedLinkEncounterId === encounter.id && <span className="text-xs text-green-400">Copied!</span>}
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteEncounter(encounter.id); }} className="p-1 rounded transition-colors text-red-400 hover:text-red-300 bg-slate-600 hover:bg-slate-500" title="Delete Encounter"><Trash2 size={18} /></button>
|
||||
@ -727,7 +753,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
<ul className="space-y-2">
|
||||
{sortedAdminParticipants.map((p, index) => {
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative)); // Ensure p.initiative is number for comparison
|
||||
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative));
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
@ -872,15 +898,30 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
const [activeEncounterData, setActiveEncounterData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!db) {
|
||||
setError("Firestore not available."); setIsLoading(false); return;
|
||||
}
|
||||
setIsLoading(true); setError(null); setActiveEncounterData(null);
|
||||
setIsLoading(true); setError(null); setActiveEncounterData(null); setCampaignBackgroundUrl('');
|
||||
let unsubscribeEncounter;
|
||||
let unsubscribeCampaign;
|
||||
|
||||
const fetchCampaignBackground = async (cId) => {
|
||||
if (!cId) return;
|
||||
const campaignDocRef = doc(db, CAMPAIGNS_COLLECTION, cId);
|
||||
unsubscribeCampaign = onSnapshot(campaignDocRef, (docSnap) => {
|
||||
if (docSnap.exists()) {
|
||||
setCampaignBackgroundUrl(docSnap.data().playerDisplayBackgroundUrl || '');
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Error fetching campaign background:", err);
|
||||
});
|
||||
};
|
||||
|
||||
if (campaignIdFromUrl && encounterIdFromUrl) {
|
||||
fetchCampaignBackground(campaignIdFromUrl);
|
||||
const encounterPath = `${CAMPAIGNS_COLLECTION}/${campaignIdFromUrl}/encounters/${encounterIdFromUrl}`;
|
||||
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
||||
if (encDocSnap.exists()) setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
|
||||
@ -893,6 +934,7 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
if (docSnap.exists()) {
|
||||
const { activeCampaignId, activeEncounterId } = docSnap.data();
|
||||
if (activeCampaignId && activeEncounterId) {
|
||||
fetchCampaignBackground(activeCampaignId);
|
||||
const encounterPath = `${CAMPAIGNS_COLLECTION}/${activeCampaignId}/encounters/${activeEncounterId}`;
|
||||
if(unsubscribeEncounter) unsubscribeEncounter();
|
||||
unsubscribeEncounter = onSnapshot(doc(db, encounterPath), (encDocSnap) => {
|
||||
@ -903,9 +945,16 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
} else { setActiveEncounterData(null); setIsLoading(false); }
|
||||
} else { setActiveEncounterData(null); setIsLoading(false); }
|
||||
}, (err) => { console.error("Error fetching active display config:", err); setError("Could not load display configuration."); setIsLoading(false); });
|
||||
return () => { unsubDisplayConfig(); if (unsubscribeEncounter) unsubscribeEncounter(); };
|
||||
return () => {
|
||||
unsubDisplayConfig();
|
||||
if (unsubscribeEncounter) unsubscribeEncounter();
|
||||
if (unsubscribeCampaign) unsubscribeCampaign();
|
||||
};
|
||||
}
|
||||
return () => { if (unsubscribeEncounter) unsubscribeEncounter(); };
|
||||
return () => {
|
||||
if (unsubscribeEncounter) unsubscribeEncounter();
|
||||
if (unsubscribeCampaign) unsubscribeCampaign();
|
||||
};
|
||||
}, [campaignIdFromUrl, encounterIdFromUrl]);
|
||||
|
||||
if (isLoading) return <div className="text-center py-10 text-2xl text-slate-300">Loading Player Display...</div>;
|
||||
@ -915,7 +964,7 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
|
||||
|
||||
let displayParticipants = [];
|
||||
if (participants) { // Ensure participants array exists before trying to sort/filter
|
||||
if (participants) {
|
||||
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
|
||||
displayParticipants = activeEncounterData.turnOrderIds
|
||||
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
|
||||
@ -932,35 +981,48 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayStyles = campaignBackgroundUrl ? {
|
||||
backgroundImage: `url(${campaignBackgroundUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
minHeight: '100vh'
|
||||
} : { minHeight: '100vh' };
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-900 rounded-xl shadow-2xl">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2">{name}</h2>
|
||||
{isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>}
|
||||
{!isStarted && participants?.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>}
|
||||
{!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-slate-500 mb-6">No participants.</p>}
|
||||
{displayParticipants.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{displayParticipants.map(p => (
|
||||
<div key={p.id} className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted ? 'text-white' : (p.type === 'character' ? 'text-sky-100' : 'text-red-100')}`}>{p.name}{p.id === currentTurnParticipantId && isStarted && <span className="text-yellow-300 animate-pulse ml-2">(Current)</span>}</h3>
|
||||
<span className={`text-xl md:text-2xl font-semibold ${p.id === currentTurnParticipantId && isStarted ? 'text-green-200' : 'text-slate-200'}`}>Init: {p.initiative}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
|
||||
<div className={`h-full rounded-full transition-all ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}></div>
|
||||
{p.type !== 'monster' && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white mix-blend-difference px-2">
|
||||
HP: {p.currentHp} / {p.maxHp}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`p-4 md:p-8 rounded-xl shadow-2xl ${!campaignBackgroundUrl ? 'bg-slate-900' : ''}`}
|
||||
style={displayStyles}
|
||||
>
|
||||
<div className={campaignBackgroundUrl ? 'bg-slate-900 bg-opacity-75 p-4 md:p-6 rounded-lg' : ''}>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center text-amber-400 mb-2">{name}</h2>
|
||||
{isStarted && <p className="text-2xl text-center text-sky-300 mb-6">Round: {round}</p>}
|
||||
{!isStarted && participants?.length > 0 && <p className="text-2xl text-center text-slate-400 mb-6">Awaiting Start</p>}
|
||||
{!isStarted && (!participants || participants.length === 0) && <p className="text-2xl text-slate-500 mb-6">No participants.</p>}
|
||||
{displayParticipants.length === 0 && isStarted && <p className="text-xl text-slate-400">No active participants.</p>}
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{displayParticipants.map(p => (
|
||||
<div key={p.id} className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${p.id === currentTurnParticipantId && isStarted ? 'bg-green-700 ring-4 ring-green-400 scale-105' : (p.type === 'character' ? 'bg-sky-700' : 'bg-red-700')} ${!p.isActive ? 'opacity-40 grayscale' : ''}`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className={`text-2xl md:text-3xl font-bold ${p.id === currentTurnParticipantId && isStarted ? 'text-white' : (p.type === 'character' ? 'text-sky-100' : 'text-red-100')}`}>{p.name}{p.id === currentTurnParticipantId && isStarted && <span className="text-yellow-300 animate-pulse ml-2">(Current)</span>}</h3>
|
||||
<span className={`text-xl md:text-2xl font-semibold ${p.id === currentTurnParticipantId && isStarted ? 'text-green-200' : 'text-slate-200'}`}>Init: {p.initiative}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-full bg-slate-600 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-slate-500">
|
||||
<div className={`h-full rounded-full transition-all ${p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500')}`} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}></div>
|
||||
{p.type !== 'monster' && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs md:text-sm font-medium text-white mix-blend-difference px-2">
|
||||
HP: {p.currentHp} / {p.maxHp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{p.conditions?.length > 0 && <p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>}
|
||||
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
|
||||
</div>
|
||||
{p.conditions?.length > 0 && <p className="text-sm text-yellow-300 mt-2">Conditions: {p.conditions.join(', ')}</p>}
|
||||
{!p.isActive && <p className="text-center text-lg font-semibold text-slate-300 mt-2">(Inactive)</p>}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -989,4 +1051,4 @@ const PlayIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.or
|
||||
const SkipForwardIcon = ({ size = 24, className = '' }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>;
|
||||
const StopCircleIcon = ({size=24, className=''}) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"></circle><rect x="9" y="9" width="6" height="6"></rect></svg>;
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
Loading…
x
Reference in New Issue
Block a user