More work.

This commit is contained in:
Robert Johnson 2025-05-25 22:21:45 -04:00
parent 290f3816c5
commit 6d7f8b182c
9 changed files with 20323 additions and 138 deletions

View File

@ -15,7 +15,7 @@ Dockerfile
# Ignore any local environment files if you have them
.env
.env.local
# .env.local
.env.development.local
.env.test.local
.env.production.local

View File

@ -1,71 +1,45 @@
# Dockerfile
# Stage 1: Build the React application
FROM node:18-alpine AS build
# Set the working directory
LABEL stage="build-local-testing"
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./
# If using yarn, uncomment the next line and comment out the npm ci line
# COPY yarn.lock ./
# Install dependencies
# If using yarn, replace 'npm ci' with 'yarn install --frozen-lockfile'
RUN npm ci
# Install dependencies using the lock file for consistency
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the application for production
# The REACT_APP_ID and REACT_APP_FIREBASE_CONFIG build arguments are optional.
# If you set them during your 'docker build' command, they will be baked into your static files.
# Otherwise, the application will rely on the __app_id and __firebase_config global variables
# being available in the environment where the built assets are served, or fall back to
# the hardcoded defaults in the React code if those globals are not present.
ARG REACT_APP_ID
ARG REACT_APP_FIREBASE_CONFIG
ENV VITE_APP_ID=$REACT_APP_ID
ENV VITE_FIREBASE_CONFIG=$REACT_APP_FIREBASE_CONFIG
# --- For Local Testing with .env.local ---
# Copy your .env.local file as .env in the build context.
# Create React App's build script will automatically load variables from this .env file.
# IMPORTANT: Ensure .env.local contains your actual Firebase API keys and other secrets.
# This .env.local file MUST be in your .gitignore and NOT committed to your repository.
# This Docker image, built this way, CONTAINS YOUR SECRETS and should NOT be pushed to a public registry.
COPY .env.local .env
# --- End Local Testing Section ---
# If your project uses Create React App (CRA - typically uses react-scripts build)
# RUN npm run build
# Build the application. react-scripts build will use environment variables
# prefixed with REACT_APP_ (either from the .env file copied above or from the build environment).
# Set NODE_OPTIONS to use the legacy OpenSSL provider for the build step.
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# If your project uses Vite (which is common for modern React setups)
# Ensure your package.json's build script uses Vite.
# If you are using Vite, you might need to adjust environment variable prefixing
# (Vite uses VITE_ for env vars to be exposed on client).
# The React code I provided doesn't assume Vite or CRA specifically, but uses
# __app_id and __firebase_config which are meant to be injected at runtime/hosting.
# For a Docker build where these are baked in, you'd typically modify the React code
# to read from process.env.REACT_APP_... (for CRA) or import.meta.env.VITE_... (for Vite).
# Assuming your React app uses environment variables like REACT_APP_ prefixed variables
# (common with Create React App) or VITE_ prefixed for Vite.
# The provided React code uses __app_id and __firebase_config which are expected
# to be injected by the hosting environment. If you want to bake these into the
# Docker image at build time, you would modify the React code to consume them
# from process.env (for CRA) or import.meta.env (for Vite) and then set them here.
# For the current React code, it expects __app_id and __firebase_config to be
# globally available where it runs. If you want to hardcode them during Docker build,
# you'd need to modify the React code to read from standard env vars and then set them
# using ENV in the Dockerfile or pass them as build ARGs.
# Let's assume a standard 'npm run build' script in your package.json
RUN npm run build
# Stage 2: Serve the static files (Optional, if you want the image to be self-contained for serving)
# If you are handling Nginx externally, you might not need this stage.
# You would just copy the /app/build directory from the 'build' stage.
# However, for completeness or if you wanted an image that *can* serve itself:
# Stage 2: Serve the static files using Nginx
FROM nginx:1.25-alpine
LABEL stage="nginx-server"
# Copy the build output from the 'build' stage to Nginx's html directory
COPY --from=build /app/build /usr/share/nginx/html
# Expose port 80 for Nginx
# Expose port 80 (Nginx default)
EXPOSE 80
# Start Nginx when the container launches
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

20085
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,29 +3,29 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0", // Optional: For testing
"@testing-library/react": "^13.4.0", // Optional: For testing
"@testing-library/user-event": "^13.5.0", // Optional: For testing
"firebase": "^10.12.2", // Firebase SDK
"lucide-react": "^0.395.0", // Icons
"react": "^18.3.1", // React library
"react-dom": "^18.3.1", // React DOM for web
"react-scripts": "5.0.1", // Scripts and configuration for Create React App
"web-vitals": "^2.1.4" // Optional: For measuring web vitals
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"firebase": "^10.12.2",
"lucide-react": "^0.395.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test", // Optional: For testing
"eject": "react-scripts eject" // Optional: For Create React App
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": { // Optional: Basic ESLint setup
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": { // Optional: Defines browser support
"browserslist": {
"production": [
">0.2%",
"not dead",

19
public/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2D3748" /> <meta
name="description"
content="A web-based TTRPG Initiative Tracker"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>TTRPG Initiative Tracker</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "TTRPG Tracker",
"name": "TTRPG Initiative Tracker",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#2D3748",
"background_color": "#1A202C"
}

View File

@ -1,11 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useRef } from 'react'; // Added useRef
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, where, writeBatch } from 'firebase/firestore';
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';
// --- Firebase Configuration ---
// Read from environment variables
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
@ -15,29 +14,34 @@ const firebaseConfig = {
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
// --- Initialize Firebase ---
// Check if all necessary Firebase config values are present
const requiredFirebaseConfigKeys = [
'apiKey', 'authDomain', 'projectId', 'appId'
// storageBucket and messagingSenderId might be optional depending on usage
];
let app;
let db;
let auth;
const requiredFirebaseConfigKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
let app;
if (missingKeys.length > 0) {
console.error(`Missing Firebase config keys from environment variables: ${missingKeys.join(', ')}`);
console.warn("Firebase is not initialized. Please set up your .env.local file with the necessary REACT_APP_FIREBASE_... variables.");
// You might want to render an error message or a fallback UI here
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 {
app = initializeApp(firebaseConfig);
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
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
}
}
const db = app ? getFirestore(app) : null; // Conditionally get Firestore
const auth = app ? getAuth(app) : null; // Conditionally get Auth
// --- Firestore Paths ---
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
// ... rest of your code
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
const CAMPAIGNS_COLLECTION = `${PUBLIC_DATA_PATH}/campaigns`;
const ACTIVE_DISPLAY_DOC = `${PUBLIC_DATA_PATH}/activeDisplay/status`;
// --- Helper Functions ---
const generateId = () => crypto.randomUUID();
@ -56,6 +60,12 @@ function App() {
const [directDisplayParams, setDirectDisplayParams] = useState(null);
useEffect(() => {
if (!auth) { // Check if Firebase auth was initialized
setError("Firebase Auth not initialized. Check your Firebase configuration.");
setIsLoading(false);
setIsAuthReady(false); // Explicitly set auth not ready
return;
}
const handleHashChange = () => {
const hash = window.location.hash;
if (hash.startsWith('#/display/')) {
@ -72,14 +82,12 @@ function App() {
};
window.addEventListener('hashchange', handleHashChange);
handleHashChange();
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
useEffect(() => {
const initAuth = async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
const token = window.__initial_auth_token;
if (token) {
await signInWithCustomToken(auth, token);
} else {
await signInAnonymously(auth);
}
@ -88,15 +96,32 @@ function App() {
setError("Failed to authenticate. Please try again later.");
}
};
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUserId(user ? user.uid : null);
setIsAuthReady(true);
setIsLoading(false);
});
initAuth();
return () => unsubscribe();
return () => {
window.removeEventListener('hashchange', handleHashChange);
unsubscribe();
};
}, []);
if (!db || !auth) { // If Firebase failed to init
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>
<p className="mt-4 text-xl">Firebase is not properly configured or initialized.</p>
<p>Please check your `.env.local` file and ensure all `REACT_APP_FIREBASE_...` variables are correctly set.</p>
<p>Also, check the browser console for more specific error messages.</p>
{error && <p className="mt-2 text-yellow-300">{error}</p>}
</div>
);
}
if (isLoading || !isAuthReady) {
return (
<div className="min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4">
@ -115,32 +140,41 @@ function App() {
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
<div className="flex items-center space-x-4">
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
{viewMode !== 'display' && ( // Only show Admin View button if not in display mode (when header is visible)
{/* 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' && !directDisplayParams ? 'bg-sky-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
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>
)}
{viewMode !== 'admin' && ( // Only show Player Display button if not in admin mode
{/* 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' && !directDisplayParams ? 'bg-teal-500 text-white' : 'bg-slate-700 hover:bg-slate-600'}`}
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>
)}
{/* If in admin mode, show player display button. If in player mode, show admin button (unless hidden above) */}
{/* This logic seems a bit complex, let's simplify: always show both if header is visible, style active one */}
{viewMode === 'admin' && ( // Show Player Display button if in Admin mode
<button
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors 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 = '';}}
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>
</div>
</div>
</header>
@ -152,11 +186,11 @@ function App() {
)}
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
{!isAuthReady && <p>Authenticating...</p>}
{!isAuthReady && !error && <p>Authenticating...</p>} {/* Show auth message only if no other error */}
</main>
{!directDisplayParams && (
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
TTRPG Initiative Tracker v0.1.12
TTRPG Initiative Tracker v0.1.13
</footer>
)}
</div>
@ -171,6 +205,7 @@ function AdminView({ userId }) {
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
useEffect(() => {
if (!db) return; // Guard against Firebase not initialized
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
const q = query(campaignsCollectionRef);
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
@ -205,7 +240,7 @@ function AdminView({ userId }) {
const handleCreateCampaign = async (name) => {
if (!name.trim()) return;
if (!db || !name.trim()) return;
const newCampaignId = generateId();
try {
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
@ -217,6 +252,7 @@ function AdminView({ userId }) {
};
const handleDeleteCampaign = async (campaignId) => {
if (!db) return;
// TODO: Implement custom confirmation modal for deleting campaigns
console.warn("Attempting to delete campaign without confirmation:", campaignId);
try {
@ -307,7 +343,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [editingCharacter, setEditingCharacter] = useState(null);
const handleAddCharacter = async () => {
if (!characterName.trim() || !campaignId) return;
if (!db ||!characterName.trim() || !campaignId) return;
const newCharacter = { id: generateId(), name: characterName.trim() };
try {
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] });
@ -316,7 +352,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
};
const handleUpdateCharacter = async (characterId, newName) => {
if (!newName.trim() || !campaignId) return;
if (!db ||!newName.trim() || !campaignId) return;
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c);
try {
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
@ -325,6 +361,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
};
const handleDeleteCharacter = async (characterId) => {
if (!db) return;
// TODO: Implement custom confirmation modal for deleting characters
console.warn("Attempting to delete character without confirmation:", characterId);
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
@ -365,14 +402,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null);
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
const selectedEncounterIdRef = React.useRef(selectedEncounterId); // Ref to track current selection for initial set
const selectedEncounterIdRef = useRef(selectedEncounterId);
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
useEffect(() => {
if (!campaignId) {
if (!db || !campaignId) {
setEncounters([]);
setSelectedEncounterId(null);
return;
@ -381,7 +418,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
setEncounters(fetchedEncounters);
if (selectedEncounterIdRef.current === null || !fetchedEncounters.some(e => e.id === selectedEncounterIdRef.current)) {
const currentSelection = selectedEncounterIdRef.current;
if (currentSelection === null || !fetchedEncounters.some(e => e.id === currentSelection)) {
if (initialActiveEncounterId && fetchedEncounters.some(e => e.id === initialActiveEncounterId)) {
setSelectedEncounterId(initialActiveEncounterId);
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
@ -396,6 +434,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
useEffect(() => {
if (!db) return;
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
setActiveDisplayInfo(docSnap.exists() ? docSnap.data() : null);
}, (err) => { console.error("Error fetching active display info:", err); setActiveDisplayInfo(null); });
@ -404,7 +443,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const handleCreateEncounter = async (name) => {
if (!name.trim() || !campaignId) return;
if (!db ||!name.trim() || !campaignId) return;
const newEncounterId = generateId();
try {
await setDoc(doc(db, encountersPath, newEncounterId), {
@ -416,6 +455,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
};
const handleDeleteEncounter = async (encounterId) => {
if (!db) return;
// TODO: Implement custom confirmation modal for deleting encounters
console.warn("Attempting to delete encounter without confirmation:", encounterId);
try {
@ -428,6 +468,7 @@ 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!");
@ -525,7 +566,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const participants = encounter.participants || [];
const handleAddParticipant = async () => {
if ((participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return;
if (!db || (participantType === 'monster' && !participantName.trim()) || (participantType === 'character' && !selectedCharacterId)) return;
let nameToAdd = participantName.trim();
if (participantType === 'character') {
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
@ -548,7 +589,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
};
const handleUpdateParticipant = async (updatedData) => {
if (!editingParticipant) return;
if (!db || !editingParticipant) return;
const { flavorText, ...restOfData } = updatedData;
const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...restOfData } : p );
try {
@ -558,6 +599,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
};
const handleDeleteParticipant = async (participantId) => {
if (!db) return;
// TODO: Implement custom confirmation modal for deleting participants
console.warn("Attempting to delete participant without confirmation:", participantId);
const updatedParticipants = participants.filter(p => p.id !== participantId);
@ -567,6 +609,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
};
const toggleParticipantActive = async (participantId) => {
if (!db) return;
const pToToggle = participants.find(p => p.id === participantId);
if (!pToToggle) return;
const updatedPs = participants.map(p => p.id === participantId ? { ...p, isActive: !p.isActive } : p);
@ -577,6 +620,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const handleHpInputChange = (participantId, value) => setHpChangeValues(prev => ({ ...prev, [participantId]: value }));
const applyHpChange = async (participantId, changeType) => {
if (!db) return;
const amountStr = hpChangeValues[participantId];
if (amountStr === undefined || amountStr.trim() === '') return;
const amount = parseInt(amountStr, 10);
@ -605,9 +649,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const handleDrop = async (e, targetId) => {
e.preventDefault();
if (draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null);
return;
if (!db || draggedItemId === null || draggedItemId === targetId) {
setDraggedItemId(null); return;
}
const currentParticipants = [...participants];
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
@ -620,7 +663,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const targetItem = currentParticipants[targetItemIndex];
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Cannot drag between different initiative groups for tie-breaking.");
setDraggedItemId(null); return;
}
const reorderedParticipants = [...currentParticipants];
@ -685,7 +727,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(p.initiative);
const isDraggable = !encounter.isStarted && tiedInitiatives.includes(Number(p.initiative)); // Ensure p.initiative is number for comparison
return (
<li
key={p.id}
@ -702,7 +744,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
`}
>
<div className="flex-1 flex items-center">
{isDraggable && <ChevronsUpDown size={18} className="mr-2 text-slate-400 flex-shrink-0" />}
{isDraggable && <ChevronsUpDown size={18} className="mr-2 text-slate-400 flex-shrink-0" title="Drag to reorder in tie"/>}
<div>
<p className={`font-semibold text-lg ${isCurrentTurn ? 'text-white' : 'text-white'}`}>{p.name} <span className="text-xs">({p.type})</span>{isCurrentTurn && <span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center"><Zap size={12} className="mr-1"/> CURRENT</span>}</p>
<p className={`text-sm ${isCurrentTurn ? 'text-green-100' : 'text-slate-200'}`}>Init: {p.initiative} | HP: {p.currentHp}/{p.maxHp}</p>
@ -756,7 +798,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleStartEncounter = async () => {
if (!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; }
if (!db ||!encounter.participants || encounter.participants.length === 0) { alert("Add participants first."); return; }
const activePs = encounter.participants.filter(p => p.isActive);
if (activePs.length === 0) { alert("No active participants."); return; }
@ -772,9 +814,6 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
await updateDoc(doc(db, encounterPath), {
isStarted: true, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id)
// Participants array in DB already reflects D&D order, so no need to update it here again unless sorting changes it.
// The `participants` field in the database should be the source of truth for the D&D order.
// The `turnOrderIds` is derived from this for active combat.
});
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
activeCampaignId: campaignId,
@ -786,7 +825,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
};
const handleNextTurn = async () => {
if (!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
if (!db ||!encounter.isStarted || !encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return;
const activePsInOrder = encounter.turnOrderIds.map(id => encounter.participants.find(p => p.id === id && p.isActive)).filter(Boolean);
if (activePsInOrder.length === 0) {
alert("No active participants left.");
@ -802,6 +841,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
};
const handleEndEncounter = async () => {
if (!db) return;
// TODO: Implement custom confirmation modal for ending encounter
console.warn("Attempting to end encounter without confirmation");
try {
@ -834,6 +874,9 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
const [error, setError] = useState(null);
useEffect(() => {
if (!db) {
setError("Firestore not available."); setIsLoading(false); return;
}
setIsLoading(true); setError(null); setActiveEncounterData(null);
let unsubscribeEncounter;
@ -872,21 +915,24 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
let displayParticipants = [];
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 && participants) {
displayParticipants = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
} else if (participants) {
displayParticipants = [...participants].filter(p => p.isActive)
.sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = participants.findIndex(p => p.id === a.id);
const indexB = participants.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative;
});
if (participants) { // Ensure participants array exists before trying to sort/filter
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
displayParticipants = activeEncounterData.turnOrderIds
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
} else {
displayParticipants = [...participants].filter(p => p.isActive)
.sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = participants.findIndex(p => p.id === a.id);
const indexB = participants.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative;
});
}
}
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>
@ -904,7 +950,7 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
<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' && ( // Only show HP text if not a monster
{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>
@ -943,4 +989,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;

25
src/index.css Normal file
View File

@ -0,0 +1,25 @@
/* src/index.css */
/* If using Tailwind CSS, you would typically import its base styles, components, and utilities here */
/* For example, if you followed Tailwind's setup guide for Create React App: */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* background-color: #1A202C; /* Tailwind Slate 900 */
/* color: #E2E8F0; /* Tailwind Slate 200 */
/* These will likely be overridden by the App component's Tailwind classes */
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Add any other global base styles here */

11
src/index.js Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css'; // Your global styles / Tailwind imports
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);