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
|
||||
.env
|
||||
.env.local
|
||||
# .env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
72
Dockerfile
72
Dockerfile
@ -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
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",
|
||||
"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
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 { 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
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