More work.
This commit is contained in:
parent
290f3816c5
commit
6d7f8b182c
@ -15,7 +15,7 @@ Dockerfile
|
|||||||
|
|
||||||
# Ignore any local environment files if you have them
|
# Ignore any local environment files if you have them
|
||||||
.env
|
.env
|
||||||
.env.local
|
# .env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
72
Dockerfile
72
Dockerfile
@ -1,71 +1,45 @@
|
|||||||
|
# Dockerfile
|
||||||
|
|
||||||
# Stage 1: Build the React application
|
# Stage 1: Build the React application
|
||||||
FROM node:18-alpine AS build
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
# Set the working directory
|
LABEL stage="build-local-testing"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json (or yarn.lock)
|
# Copy package.json and package-lock.json (or yarn.lock)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# If using yarn, uncomment the next line and comment out the npm ci line
|
|
||||||
# COPY yarn.lock ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies using the lock file for consistency
|
||||||
# If using yarn, replace 'npm ci' with 'yarn install --frozen-lockfile'
|
RUN npm install
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy the rest of the application source code
|
# Copy the rest of the application source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application for production
|
# --- For Local Testing with .env.local ---
|
||||||
# The REACT_APP_ID and REACT_APP_FIREBASE_CONFIG build arguments are optional.
|
# Copy your .env.local file as .env in the build context.
|
||||||
# If you set them during your 'docker build' command, they will be baked into your static files.
|
# Create React App's build script will automatically load variables from this .env file.
|
||||||
# Otherwise, the application will rely on the __app_id and __firebase_config global variables
|
# IMPORTANT: Ensure .env.local contains your actual Firebase API keys and other secrets.
|
||||||
# being available in the environment where the built assets are served, or fall back to
|
# This .env.local file MUST be in your .gitignore and NOT committed to your repository.
|
||||||
# the hardcoded defaults in the React code if those globals are not present.
|
# This Docker image, built this way, CONTAINS YOUR SECRETS and should NOT be pushed to a public registry.
|
||||||
ARG REACT_APP_ID
|
COPY .env.local .env
|
||||||
ARG REACT_APP_FIREBASE_CONFIG
|
# --- End Local Testing Section ---
|
||||||
ENV VITE_APP_ID=$REACT_APP_ID
|
|
||||||
ENV VITE_FIREBASE_CONFIG=$REACT_APP_FIREBASE_CONFIG
|
|
||||||
|
|
||||||
# If your project uses Create React App (CRA - typically uses react-scripts build)
|
# Build the application. react-scripts build will use environment variables
|
||||||
# RUN npm run build
|
# 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)
|
# Stage 2: Serve the static files using Nginx
|
||||||
# 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:
|
|
||||||
FROM nginx:1.25-alpine
|
FROM nginx:1.25-alpine
|
||||||
|
|
||||||
|
LABEL stage="nginx-server"
|
||||||
|
|
||||||
# Copy the build output from the 'build' stage to Nginx's html directory
|
# Copy the build output from the 'build' stage to Nginx's html directory
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
# Expose port 80 for Nginx
|
# Expose port 80 (Nginx default)
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start Nginx when the container launches
|
# Start Nginx when the container launches
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
20085
package-lock.json
generated
Normal file
20085
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -3,29 +3,29 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/jest-dom": "^5.17.0", // Optional: For testing
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0", // Optional: For testing
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0", // Optional: For testing
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"firebase": "^10.12.2", // Firebase SDK
|
"firebase": "^10.12.2",
|
||||||
"lucide-react": "^0.395.0", // Icons
|
"lucide-react": "^0.395.0",
|
||||||
"react": "^18.3.1", // React library
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1", // React DOM for web
|
"react-dom": "^18.3.1",
|
||||||
"react-scripts": "5.0.1", // Scripts and configuration for Create React App
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4" // Optional: For measuring web vitals
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test", // Optional: For testing
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject" // Optional: For Create React App
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": { // Optional: Basic ESLint setup
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
"react-app/jest"
|
"react-app/jest"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browserslist": { // Optional: Defines browser support
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
"not dead",
|
"not dead",
|
||||||
|
19
public/index.html
Normal file
19
public/index.html
Normal 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
25
public/manifest.json
Normal 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"
|
||||||
|
}
|
@ -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 { initializeApp } from 'firebase/app';
|
||||||
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
|
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';
|
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 ---
|
// --- Firebase Configuration ---
|
||||||
// Read from environment variables
|
|
||||||
const firebaseConfig = {
|
const firebaseConfig = {
|
||||||
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||||
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
||||||
@ -15,29 +14,34 @@ const firebaseConfig = {
|
|||||||
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Initialize Firebase ---
|
let app;
|
||||||
// Check if all necessary Firebase config values are present
|
let db;
|
||||||
const requiredFirebaseConfigKeys = [
|
let auth;
|
||||||
'apiKey', 'authDomain', 'projectId', 'appId'
|
|
||||||
// storageBucket and messagingSenderId might be optional depending on usage
|
const requiredFirebaseConfigKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
||||||
];
|
|
||||||
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
|
const missingKeys = requiredFirebaseConfigKeys.filter(key => !firebaseConfig[key]);
|
||||||
|
|
||||||
let app;
|
|
||||||
if (missingKeys.length > 0) {
|
if (missingKeys.length > 0) {
|
||||||
console.error(`Missing Firebase config keys from environment variables: ${missingKeys.join(', ')}`);
|
console.error(`CRITICAL: Missing Firebase config values 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.");
|
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.");
|
||||||
// You might want to render an error message or a fallback UI here
|
// Fallback: Render a message or allow app to break if Firebase is critical
|
||||||
} else {
|
} 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 ---
|
// --- Firestore Paths ---
|
||||||
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
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 ---
|
// --- Helper Functions ---
|
||||||
const generateId = () => crypto.randomUUID();
|
const generateId = () => crypto.randomUUID();
|
||||||
@ -56,6 +60,12 @@ function App() {
|
|||||||
const [directDisplayParams, setDirectDisplayParams] = useState(null);
|
const [directDisplayParams, setDirectDisplayParams] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 handleHashChange = () => {
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (hash.startsWith('#/display/')) {
|
if (hash.startsWith('#/display/')) {
|
||||||
@ -72,14 +82,12 @@ function App() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('hashchange', handleHashChange);
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
handleHashChange();
|
handleHashChange();
|
||||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
try {
|
try {
|
||||||
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
|
const token = window.__initial_auth_token;
|
||||||
await signInWithCustomToken(auth, __initial_auth_token);
|
if (token) {
|
||||||
|
await signInWithCustomToken(auth, token);
|
||||||
} else {
|
} else {
|
||||||
await signInAnonymously(auth);
|
await signInAnonymously(auth);
|
||||||
}
|
}
|
||||||
@ -88,15 +96,32 @@ function App() {
|
|||||||
setError("Failed to authenticate. Please try again later.");
|
setError("Failed to authenticate. Please try again later.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
setUserId(user ? user.uid : null);
|
setUserId(user ? user.uid : null);
|
||||||
setIsAuthReady(true);
|
setIsAuthReady(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
initAuth();
|
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) {
|
if (isLoading || !isAuthReady) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 text-white flex flex-col items-center justify-center p-4">
|
<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>
|
<h1 className="text-3xl font-bold text-sky-400">TTRPG Initiative Tracker</h1>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{userId && <span className="text-xs text-slate-400">UID: {userId}</span>}
|
{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
|
<button
|
||||||
onClick={() => { setViewMode('admin'); setDirectDisplayParams(null); window.location.hash = '';}}
|
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
|
Admin View
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
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
|
Player Display
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* If in admin mode, show player display button. If in player mode, show admin button (unless hidden above) */}
|
{/* Simpler toggle: always show both if header is visible and style the active one */}
|
||||||
{/* This logic seems a bit complex, let's simplify: always show both if header is visible, style active one */}
|
{/* The above logic is slightly off for two buttons always present. Let's fix: */}
|
||||||
{viewMode === 'admin' && ( // Show Player Display button if in Admin mode
|
{/* Corrected Header Buttons for Toggling */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setViewMode('display'); setDirectDisplayParams(null); window.location.hash = '';}}
|
onClick={() => { setViewMode('admin'); 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`}
|
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
|
||||||
Player Display
|
style={viewMode === 'display' ? { display: 'none' } : {}}
|
||||||
</button>
|
>
|
||||||
)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -152,11 +186,11 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
|
{!directDisplayParams && viewMode === 'admin' && isAuthReady && userId && <AdminView userId={userId} />}
|
||||||
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
|
{!directDisplayParams && viewMode === 'display' && isAuthReady && <DisplayView />}
|
||||||
{!isAuthReady && <p>Authenticating...</p>}
|
{!isAuthReady && !error && <p>Authenticating...</p>} {/* Show auth message only if no other error */}
|
||||||
</main>
|
</main>
|
||||||
{!directDisplayParams && (
|
{!directDisplayParams && (
|
||||||
<footer className="bg-slate-900 p-4 text-center text-sm text-slate-400 mt-8">
|
<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>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -171,6 +205,7 @@ function AdminView({ userId }) {
|
|||||||
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
|
const [initialActiveInfo, setInitialActiveInfo] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!db) return; // Guard against Firebase not initialized
|
||||||
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
|
const campaignsCollectionRef = collection(db, CAMPAIGNS_COLLECTION);
|
||||||
const q = query(campaignsCollectionRef);
|
const q = query(campaignsCollectionRef);
|
||||||
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
|
const unsubscribeCampaigns = onSnapshot(q, (snapshot) => {
|
||||||
@ -205,7 +240,7 @@ function AdminView({ userId }) {
|
|||||||
|
|
||||||
|
|
||||||
const handleCreateCampaign = async (name) => {
|
const handleCreateCampaign = async (name) => {
|
||||||
if (!name.trim()) return;
|
if (!db || !name.trim()) return;
|
||||||
const newCampaignId = generateId();
|
const newCampaignId = generateId();
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
|
await setDoc(doc(db, CAMPAIGNS_COLLECTION, newCampaignId), {
|
||||||
@ -217,6 +252,7 @@ function AdminView({ userId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCampaign = async (campaignId) => {
|
const handleDeleteCampaign = async (campaignId) => {
|
||||||
|
if (!db) return;
|
||||||
// TODO: Implement custom confirmation modal for deleting campaigns
|
// TODO: Implement custom confirmation modal for deleting campaigns
|
||||||
console.warn("Attempting to delete campaign without confirmation:", campaignId);
|
console.warn("Attempting to delete campaign without confirmation:", campaignId);
|
||||||
try {
|
try {
|
||||||
@ -307,7 +343,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
const [editingCharacter, setEditingCharacter] = useState(null);
|
const [editingCharacter, setEditingCharacter] = useState(null);
|
||||||
|
|
||||||
const handleAddCharacter = async () => {
|
const handleAddCharacter = async () => {
|
||||||
if (!characterName.trim() || !campaignId) return;
|
if (!db ||!characterName.trim() || !campaignId) return;
|
||||||
const newCharacter = { id: generateId(), name: characterName.trim() };
|
const newCharacter = { id: generateId(), name: characterName.trim() };
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] });
|
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: [...campaignCharacters, newCharacter] });
|
||||||
@ -316,7 +352,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCharacter = async (characterId, newName) => {
|
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);
|
const updatedCharacters = campaignCharacters.map(c => c.id === characterId ? { ...c, name: newName.trim() } : c);
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
|
await updateDoc(doc(db, CAMPAIGNS_COLLECTION, campaignId), { players: updatedCharacters });
|
||||||
@ -325,6 +361,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCharacter = async (characterId) => {
|
const handleDeleteCharacter = async (characterId) => {
|
||||||
|
if (!db) return;
|
||||||
// TODO: Implement custom confirmation modal for deleting characters
|
// TODO: Implement custom confirmation modal for deleting characters
|
||||||
console.warn("Attempting to delete character without confirmation:", characterId);
|
console.warn("Attempting to delete character without confirmation:", characterId);
|
||||||
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
|
const updatedCharacters = campaignCharacters.filter(c => c.id !== characterId);
|
||||||
@ -365,14 +402,14 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
|
const [activeDisplayInfo, setActiveDisplayInfo] = useState(null);
|
||||||
const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null);
|
const [copiedLinkEncounterId, setCopiedLinkEncounterId] = useState(null);
|
||||||
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
const encountersPath = `${CAMPAIGNS_COLLECTION}/${campaignId}/encounters`;
|
||||||
const selectedEncounterIdRef = React.useRef(selectedEncounterId); // Ref to track current selection for initial set
|
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedEncounterIdRef.current = selectedEncounterId;
|
selectedEncounterIdRef.current = selectedEncounterId;
|
||||||
}, [selectedEncounterId]);
|
}, [selectedEncounterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!campaignId) {
|
if (!db || !campaignId) {
|
||||||
setEncounters([]);
|
setEncounters([]);
|
||||||
setSelectedEncounterId(null);
|
setSelectedEncounterId(null);
|
||||||
return;
|
return;
|
||||||
@ -381,7 +418,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
const fetchedEncounters = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||||
setEncounters(fetchedEncounters);
|
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)) {
|
if (initialActiveEncounterId && fetchedEncounters.some(e => e.id === initialActiveEncounterId)) {
|
||||||
setSelectedEncounterId(initialActiveEncounterId);
|
setSelectedEncounterId(initialActiveEncounterId);
|
||||||
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
|
} else if (activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId &&
|
||||||
@ -396,6 +434,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!db) return;
|
||||||
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
|
const unsub = onSnapshot(doc(db, ACTIVE_DISPLAY_DOC), (docSnap) => {
|
||||||
setActiveDisplayInfo(docSnap.exists() ? docSnap.data() : null);
|
setActiveDisplayInfo(docSnap.exists() ? docSnap.data() : null);
|
||||||
}, (err) => { console.error("Error fetching active display info:", err); setActiveDisplayInfo(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) => {
|
const handleCreateEncounter = async (name) => {
|
||||||
if (!name.trim() || !campaignId) return;
|
if (!db ||!name.trim() || !campaignId) return;
|
||||||
const newEncounterId = generateId();
|
const newEncounterId = generateId();
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, encountersPath, newEncounterId), {
|
await setDoc(doc(db, encountersPath, newEncounterId), {
|
||||||
@ -416,6 +455,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEncounter = async (encounterId) => {
|
const handleDeleteEncounter = async (encounterId) => {
|
||||||
|
if (!db) return;
|
||||||
// TODO: Implement custom confirmation modal for deleting encounters
|
// TODO: Implement custom confirmation modal for deleting encounters
|
||||||
console.warn("Attempting to delete encounter without confirmation:", encounterId);
|
console.warn("Attempting to delete encounter without confirmation:", encounterId);
|
||||||
try {
|
try {
|
||||||
@ -428,6 +468,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
|
const handleSetEncounterAsActiveDisplay = async (encounterId) => {
|
||||||
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true });
|
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), { activeCampaignId: campaignId, activeEncounterId: encounterId }, { merge: true });
|
||||||
console.log("Encounter set as active for DM's main display!");
|
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 participants = encounter.participants || [];
|
||||||
|
|
||||||
const handleAddParticipant = async () => {
|
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();
|
let nameToAdd = participantName.trim();
|
||||||
if (participantType === 'character') {
|
if (participantType === 'character') {
|
||||||
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
|
const character = campaignCharacters.find(c => c.id === selectedCharacterId);
|
||||||
@ -548,7 +589,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateParticipant = async (updatedData) => {
|
const handleUpdateParticipant = async (updatedData) => {
|
||||||
if (!editingParticipant) return;
|
if (!db || !editingParticipant) return;
|
||||||
const { flavorText, ...restOfData } = updatedData;
|
const { flavorText, ...restOfData } = updatedData;
|
||||||
const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...restOfData } : p );
|
const updatedParticipants = participants.map(p => p.id === editingParticipant.id ? { ...p, ...restOfData } : p );
|
||||||
try {
|
try {
|
||||||
@ -558,6 +599,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteParticipant = async (participantId) => {
|
const handleDeleteParticipant = async (participantId) => {
|
||||||
|
if (!db) return;
|
||||||
// TODO: Implement custom confirmation modal for deleting participants
|
// TODO: Implement custom confirmation modal for deleting participants
|
||||||
console.warn("Attempting to delete participant without confirmation:", participantId);
|
console.warn("Attempting to delete participant without confirmation:", participantId);
|
||||||
const updatedParticipants = participants.filter(p => p.id !== participantId);
|
const updatedParticipants = participants.filter(p => p.id !== participantId);
|
||||||
@ -567,6 +609,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleParticipantActive = async (participantId) => {
|
const toggleParticipantActive = async (participantId) => {
|
||||||
|
if (!db) return;
|
||||||
const pToToggle = participants.find(p => p.id === participantId);
|
const pToToggle = participants.find(p => p.id === participantId);
|
||||||
if (!pToToggle) return;
|
if (!pToToggle) return;
|
||||||
const updatedPs = participants.map(p => p.id === participantId ? { ...p, isActive: !p.isActive } : p);
|
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 handleHpInputChange = (participantId, value) => setHpChangeValues(prev => ({ ...prev, [participantId]: value }));
|
||||||
|
|
||||||
const applyHpChange = async (participantId, changeType) => {
|
const applyHpChange = async (participantId, changeType) => {
|
||||||
|
if (!db) return;
|
||||||
const amountStr = hpChangeValues[participantId];
|
const amountStr = hpChangeValues[participantId];
|
||||||
if (amountStr === undefined || amountStr.trim() === '') return;
|
if (amountStr === undefined || amountStr.trim() === '') return;
|
||||||
const amount = parseInt(amountStr, 10);
|
const amount = parseInt(amountStr, 10);
|
||||||
@ -605,9 +649,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
|
|
||||||
const handleDrop = async (e, targetId) => {
|
const handleDrop = async (e, targetId) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedItemId === null || draggedItemId === targetId) {
|
if (!db || draggedItemId === null || draggedItemId === targetId) {
|
||||||
setDraggedItemId(null);
|
setDraggedItemId(null); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const currentParticipants = [...participants];
|
const currentParticipants = [...participants];
|
||||||
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
|
const draggedItemIndex = currentParticipants.findIndex(p => p.id === draggedItemId);
|
||||||
@ -620,7 +663,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
const targetItem = currentParticipants[targetItemIndex];
|
const targetItem = currentParticipants[targetItemIndex];
|
||||||
|
|
||||||
if (draggedItem.initiative !== targetItem.initiative) {
|
if (draggedItem.initiative !== targetItem.initiative) {
|
||||||
console.log("Cannot drag between different initiative groups for tie-breaking.");
|
|
||||||
setDraggedItemId(null); return;
|
setDraggedItemId(null); return;
|
||||||
}
|
}
|
||||||
const reorderedParticipants = [...currentParticipants];
|
const reorderedParticipants = [...currentParticipants];
|
||||||
@ -685,7 +727,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sortedAdminParticipants.map((p, index) => {
|
{sortedAdminParticipants.map((p, index) => {
|
||||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@ -702,7 +744,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex items-center">
|
<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>
|
<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={`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>
|
<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 }) {
|
function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
||||||
const handleStartEncounter = async () => {
|
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);
|
const activePs = encounter.participants.filter(p => p.isActive);
|
||||||
if (activePs.length === 0) { alert("No active participants."); return; }
|
if (activePs.length === 0) { alert("No active participants."); return; }
|
||||||
|
|
||||||
@ -772,9 +814,6 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, encounterPath), {
|
await updateDoc(doc(db, encounterPath), {
|
||||||
isStarted: true, round: 1, currentTurnParticipantId: sortedPs[0].id, turnOrderIds: sortedPs.map(p => p.id)
|
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), {
|
await setDoc(doc(db, ACTIVE_DISPLAY_DOC), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
@ -786,7 +825,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNextTurn = async () => {
|
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);
|
const activePsInOrder = encounter.turnOrderIds.map(id => encounter.participants.find(p => p.id === id && p.isActive)).filter(Boolean);
|
||||||
if (activePsInOrder.length === 0) {
|
if (activePsInOrder.length === 0) {
|
||||||
alert("No active participants left.");
|
alert("No active participants left.");
|
||||||
@ -802,6 +841,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEndEncounter = async () => {
|
const handleEndEncounter = async () => {
|
||||||
|
if (!db) return;
|
||||||
// TODO: Implement custom confirmation modal for ending encounter
|
// TODO: Implement custom confirmation modal for ending encounter
|
||||||
console.warn("Attempting to end encounter without confirmation");
|
console.warn("Attempting to end encounter without confirmation");
|
||||||
try {
|
try {
|
||||||
@ -834,6 +874,9 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!db) {
|
||||||
|
setError("Firestore not available."); setIsLoading(false); return;
|
||||||
|
}
|
||||||
setIsLoading(true); setError(null); setActiveEncounterData(null);
|
setIsLoading(true); setError(null); setActiveEncounterData(null);
|
||||||
let unsubscribeEncounter;
|
let unsubscribeEncounter;
|
||||||
|
|
||||||
@ -872,21 +915,24 @@ function DisplayView({ campaignIdFromUrl, encounterIdFromUrl }) {
|
|||||||
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
|
const { name, participants, round, currentTurnParticipantId, isStarted } = activeEncounterData;
|
||||||
|
|
||||||
let displayParticipants = [];
|
let displayParticipants = [];
|
||||||
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 && participants) {
|
if (participants) { // Ensure participants array exists before trying to sort/filter
|
||||||
displayParticipants = activeEncounterData.turnOrderIds
|
if (isStarted && activeEncounterData.turnOrderIds?.length > 0 ) {
|
||||||
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
|
displayParticipants = activeEncounterData.turnOrderIds
|
||||||
} else if (participants) {
|
.map(id => participants.find(p => p.id === id)).filter(p => p && p.isActive);
|
||||||
displayParticipants = [...participants].filter(p => p.isActive)
|
} else {
|
||||||
.sort((a, b) => {
|
displayParticipants = [...participants].filter(p => p.isActive)
|
||||||
if (a.initiative === b.initiative) {
|
.sort((a, b) => {
|
||||||
const indexA = participants.findIndex(p => p.id === a.id);
|
if (a.initiative === b.initiative) {
|
||||||
const indexB = participants.findIndex(p => p.id === b.id);
|
const indexA = participants.findIndex(p => p.id === a.id);
|
||||||
return indexA - indexB;
|
const indexB = participants.findIndex(p => p.id === b.id);
|
||||||
}
|
return indexA - indexB;
|
||||||
return b.initiative - a.initiative;
|
}
|
||||||
});
|
return b.initiative - a.initiative;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 bg-slate-900 rounded-xl shadow-2xl">
|
<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>
|
<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="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="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>
|
<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">
|
<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}
|
HP: {p.currentHp} / {p.maxHp}
|
||||||
</span>
|
</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 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>;
|
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
25
src/index.css
Normal 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
11
src/index.js
Normal 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>
|
||||||
|
);
|
Loading…
x
Reference in New Issue
Block a user