first release! 0.3.6

This commit is contained in:
cottongin
2025-10-30 19:27:23 -04:00
parent 47db3890e2
commit 6308d99d33
23 changed files with 4156 additions and 35 deletions

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ node_modules/
frontend/dist/
frontend/build/
# Generated files
frontend/public/manifest.json
# Logs
*.log
npm-debug.log*

View File

@@ -4,6 +4,12 @@ A full-stack web application that helps groups pick games to play from various J
## Features
### Progressive Web App (PWA)
- **Installable**: Add to home screen on mobile and desktop devices
- **Offline Support**: Service worker provides offline functionality
- **Native Experience**: Runs like a native app when installed
- **Auto-updates**: Seamlessly updates to new versions
### Admin Features
- **Game Picker**: Randomly select games with intelligent filters
- Filter by player count, drawing games, game length, and family-friendly status
@@ -130,6 +136,34 @@ The backend will run on http://localhost:5000
The frontend will run on http://localhost:3000 and proxy API requests to the backend.
## Configuration
### Branding and Metadata
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
- **App Name** and **Short Name** - Displayed in UI and when installed as PWA
- **Description** - Shown in search engines and app stores
- **Version** - Current app version
- **Theme Color** - Primary color for browser chrome and PWA theme
- **Keywords** - SEO metadata
- **Author** - Creator/maintainer information
- **Links** - GitHub repo, support contact, etc.
When you update `branding.js`, the following are automatically synchronized:
1. **PWA Manifest** (`manifest.json`) - Generated at build time via `generate-manifest.js`
2. **HTML Meta Tags** - Updated via Vite HTML transformation plugin
3. **App UI** - Components import branding directly
To regenerate the manifest manually:
```bash
cd frontend
npm run generate-manifest
```
The manifest is automatically generated during the build process, so you don't need to edit it directly.
## Project Structure
```

93
frontend/ICONS.md Normal file
View File

@@ -0,0 +1,93 @@
# Icon Generation Guide
## Current Icons
-`public/favicon.svg` - Primary icon (SVG format)
## Missing Icons (Optional but Recommended)
For optimal PWA support, especially on iOS/Safari, you should generate PNG versions:
- `public/icon-192.png` - 192x192px PNG
- `public/icon-512.png` - 512x512px PNG
## How to Generate PNG Icons
### Option 1: Online Converter (Easiest)
1. Go to https://realfavicongenerator.net/ or https://favicon.io/
2. Upload `public/favicon.svg`
3. Generate and download PNG versions
4. Save as `icon-192.png` and `icon-512.png` in `frontend/public/`
### Option 2: Using ImageMagick (Command Line)
If you have ImageMagick installed:
```bash
cd frontend/public
# Generate 192x192
convert favicon.svg -resize 192x192 icon-192.png
# Generate 512x512
convert favicon.svg -resize 512x512 icon-512.png
```
### Option 3: Using Node.js Script
Install sharp library temporarily:
```bash
npm install --save-dev sharp
```
Create and run this script:
```javascript
// generate-icons.js
const sharp = require('sharp');
const fs = require('fs');
const sizes = [192, 512];
const svgBuffer = fs.readFileSync('./public/favicon.svg');
sizes.forEach(size => {
sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(`./public/icon-${size}.png`)
.then(() => console.log(`✅ Generated icon-${size}.png`))
.catch(err => console.error(`❌ Failed to generate icon-${size}.png:`, err));
});
```
Run it:
```bash
node generate-icons.js
```
Then uninstall sharp:
```bash
npm uninstall sharp
```
## What Happens Without PNG Icons?
The app will still work as a PWA! Modern browsers (Chrome, Edge, Firefox) support SVG icons just fine. However:
- **iOS Safari** may not display the icon correctly on the home screen
- Some older Android devices might show a generic icon
- The manifest references PNG files as fallbacks
The SVG will be used as a fallback, which works on most platforms.
## Why We Don't Auto-Generate
PNG generation requires either:
- Native image processing libraries (platform-dependent)
- External dependencies that bloat the build
- Build-time processing that slows down development
Since the app works fine with SVG on most platforms, we leave PNG generation as an optional step.

View File

@@ -0,0 +1,74 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Import branding config dynamically
const brandingModule = await import('./src/config/branding.js');
const branding = brandingModule.branding;
const manifest = {
name: branding.app.name,
short_name: branding.app.shortName,
description: branding.app.description,
start_url: "/",
display: "standalone",
background_color: "#1f2937",
theme_color: branding.meta.themeColor,
orientation: "any",
scope: "/",
icons: [
{
src: "/favicon.svg",
sizes: "any",
type: "image/svg+xml"
},
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any maskable"
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable"
},
{
src: "/favicon.svg",
sizes: "512x512",
type: "image/svg+xml",
purpose: "any"
}
],
screenshots: [],
categories: ["entertainment", "games", "utilities"],
shortcuts: [
{
name: "Pick a Game",
short_name: "Pick",
description: "Go directly to the game picker",
url: "/picker",
icons: []
},
{
name: "Session History",
short_name: "History",
description: "View past gaming sessions",
url: "/history",
icons: []
}
]
};
// Write manifest to public directory
const publicDir = path.join(__dirname, 'public');
const manifestPath = path.join(publicDir, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
console.log('✅ Generated manifest.json from branding config');

View File

@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html>
<head>
<title>PNG Icon Generator</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-top: 0;
}
.preview {
display: flex;
gap: 20px;
margin: 20px 0;
align-items: center;
}
canvas {
border: 2px solid #e5e7eb;
border-radius: 8px;
}
button {
background: #4f46e5;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #4338ca;
}
.info {
background: #eff6ff;
border-left: 4px solid #3b82f6;
padding: 12px;
margin: 20px 0;
border-radius: 4px;
}
.success {
background: #f0fdf4;
border-left-color: #22c55e;
color: #166534;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 PWA Icon Generator</h1>
<div class="info">
<strong>Instructions:</strong> This tool will generate PNG icons from your SVG favicon.
Click the buttons below to download the required icon sizes for PWA support.
</div>
<div class="preview">
<div>
<h3>192x192</h3>
<canvas id="canvas192" width="192" height="192"></canvas>
<br>
<button onclick="downloadIcon(192)">📥 Download 192x192</button>
</div>
<div>
<h3>512x512</h3>
<canvas id="canvas512" width="512" height="512"></canvas>
<br>
<button onclick="downloadIcon(512)">📥 Download 512x512</button>
</div>
</div>
<div id="status"></div>
<div class="info">
<strong>After downloading:</strong>
<ol>
<li>Save both files to <code>frontend/public/</code></li>
<li>Rename them to <code>icon-192.png</code> and <code>icon-512.png</code></li>
<li>Rebuild your Docker containers</li>
</ol>
</div>
</div>
<script>
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Dice/Box shape -->
<rect x="10" y="10" width="80" height="80" rx="12" fill="url(#grad)"/>
<!-- Dots representing game selection -->
<circle cx="30" cy="30" r="6" fill="white" opacity="0.9"/>
<circle cx="50" cy="30" r="6" fill="white" opacity="0.9"/>
<circle cx="70" cy="30" r="6" fill="white" opacity="0.9"/>
<circle cx="30" cy="50" r="6" fill="white" opacity="0.9"/>
<circle cx="50" cy="50" r="6" fill="white" opacity="1"/>
<circle cx="70" cy="50" r="6" fill="white" opacity="0.9"/>
<circle cx="30" cy="70" r="6" fill="white" opacity="0.9"/>
<circle cx="50" cy="70" r="6" fill="white" opacity="0.9"/>
<circle cx="70" cy="70" r="6" fill="white" opacity="0.9"/>
</svg>`;
function drawIcon(size) {
const canvas = document.getElementById(`canvas${size}`);
const ctx = canvas.getContext('2d');
const img = new Image();
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = function() {
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
};
img.src = url;
}
function downloadIcon(size) {
const canvas = document.getElementById(`canvas${size}`);
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `icon-${size}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const status = document.getElementById('status');
status.innerHTML = `<div class="info success">✅ Downloaded icon-${size}.png! Save it to frontend/public/</div>`;
setTimeout(() => status.innerHTML = '', 3000);
});
}
// Draw icons on page load
window.addEventListener('load', () => {
drawIcon(192);
drawIcon(512);
});
</script>
</body>
</html>

View File

@@ -4,22 +4,37 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Branding -->
<title>Jackbox Game Picker</title>
<meta name="description" content="A web app for managing and picking Jackbox Party Pack games" />
<meta name="keywords" content="jackbox, party pack, game picker, multiplayer games" />
<meta name="author" content="Jackbox Game Picker" />
<!-- Branding (populated by vite.config.js from branding.js) -->
<title>HSO Jackbox Game Picker</title>
<meta name="description" content="Spicing up Hyper Spaceout game nights!" />
<meta name="keywords" content="hso, hyper spaceout, jackbox, party pack, game picker, multiplayer games" />
<meta name="author" content="cottongin" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Theme color -->
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Mobile Web App -->
<meta name="mobile-web-app-capable" content="yes" />
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="HSO JGP" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.svg" />
<!-- Theme color (populated by vite.config.js from branding.js) -->
<meta name="theme-color" content="#4F46E5" />
<!-- Open Graph / Social Media -->
<!-- Open Graph / Social Media (populated by vite.config.js from branding.js) -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Jackbox Game Picker" />
<meta property="og:description" content="A web app for managing and picking Jackbox Party Pack games" />
<meta property="og:title" content="HSO Jackbox Game Picker" />
<meta property="og:description" content="Spicing up Hyper Spaceout game nights!" />
<!-- Prevent flash of unstyled content in dark mode -->
<script>

View File

@@ -18,6 +18,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Service Worker - no caching!
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
add_header Service-Worker-Allowed "/";
}
# PWA Manifest
location = /manifest.json {
add_header Cache-Control "public, max-age=86400";
add_header Content-Type "application/manifest+json";
}
# React routing
location / {
try_files $uri $uri/ /index.html;

3378
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"name": "jackbox-game-picker-frontend",
"version": "1.0.0",
"description": "Frontend for Jackbox Party Pack Game Picker",
"type": "module",
"private": true,
"dependencies": {
"react": "^18.2.0",
@@ -20,8 +21,9 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"build": "node generate-manifest.js && vite build",
"preview": "vite preview",
"generate-manifest": "node generate-manifest.js"
}
}

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

69
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,69 @@
const CACHE_NAME = 'jackbox-picker-v1';
const urlsToCache = [
'/',
'/index.html',
'/favicon.ico'
];
// Install service worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
self.skipWaiting();
});
// Activate service worker
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// Fetch strategy: Network first, fallback to cache
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip chrome-extension and other non-http(s) requests
if (!event.request.url.startsWith('http')) {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// Don't cache API responses or non-successful responses
if (event.request.url.includes('/api/') || !response || response.status !== 200) {
return response;
}
// Clone the response
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
});

View File

@@ -5,6 +5,8 @@ import { ToastProvider } from './components/Toast';
import { branding } from './config/branding';
import Logo from './components/Logo';
import ThemeToggle from './components/ThemeToggle';
import InstallPrompt from './components/InstallPrompt';
import SafariInstallPrompt from './components/SafariInstallPrompt';
import Home from './pages/Home';
import Login from './pages/Login';
import Picker from './pages/Picker';
@@ -177,6 +179,10 @@ function App() {
</div>
</div>
</footer>
{/* PWA Install Prompts */}
<InstallPrompt />
<SafariInstallPrompt />
</div>
</ToastProvider>
);

View File

@@ -0,0 +1,97 @@
import React, { useState, useEffect } from 'react';
function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
// Check if Safari (which doesn't support beforeinstallprompt)
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
return; // Don't show this prompt on Safari, use SafariInstallPrompt instead
}
const handler = (e) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Save the event so it can be triggered later
setDeferredPrompt(e);
// Show our custom install prompt
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to install prompt: ${outcome}`);
// Clear the saved prompt
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
// Remember dismissal for this session
sessionStorage.setItem('installPromptDismissed', 'true');
};
// Don't show if already dismissed in this session
if (sessionStorage.getItem('installPromptDismissed')) {
return null;
}
if (!showPrompt) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-3xl">
📱
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
Install App
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Install Jackbox Game Picker for quick access and offline support!
</p>
<div className="flex gap-2">
<button
onClick={handleInstall}
className="flex-1 bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-medium text-sm"
>
Install
</button>
<button
onClick={handleDismiss}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm"
>
Not now
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default InstallPrompt;

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect } from 'react';
function SafariInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
// Check if running in standalone mode (already installed)
const standalone = window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
setIsStandalone(standalone);
// Check if Safari on iOS or macOS
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isMacOS = navigator.platform.includes('Mac') && !isIOS;
// Show prompt if Safari and not already installed
if ((isSafari || isIOS) && !standalone && !sessionStorage.getItem('safariInstallPromptDismissed')) {
// Wait a bit before showing to not overwhelm user
const timer = setTimeout(() => {
setShowPrompt(true);
}, 3000);
return () => clearTimeout(timer);
}
}, []);
const handleDismiss = () => {
setShowPrompt(false);
sessionStorage.setItem('safariInstallPromptDismissed', 'true');
};
// Don't show if already installed
if (isStandalone || !showPrompt) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-3xl">
🍎
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
Install as App
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tap the Share button <span className="inline-block w-4 h-4 align-middle">
<svg viewBox="0 0 50 50" className="fill-current">
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/>
<path d="M24 7h2v21h-2z"/>
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
</svg>
</span> and select "Add to Home Screen"
</p>
<button
onClick={handleDismiss}
className="w-full text-center px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm border border-gray-300 dark:border-gray-600 rounded-lg"
>
Got it
</button>
</div>
</div>
</div>
</div>
);
}
export default SafariInstallPrompt;

View File

@@ -1,8 +1,8 @@
export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'HSO JGP',
version: '0.3.2 - Safari Walkabout Edition',
shortName: 'Jackbox Game Picker',
version: '0.3.6 - Safari Walkabout Edition',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {

View File

@@ -2,6 +2,21 @@
@tailwind components;
@tailwind utilities;
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slideUp {
animation: slideUp 0.3s ease-out;
}
@layer base {
body {
@apply antialiased;

View File

@@ -18,3 +18,16 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</React.StrictMode>,
);
// Register service worker for PWA support
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration);
})
.catch((error) => {
console.log('Service Worker registration failed:', error);
});
});
}

View File

@@ -31,8 +31,8 @@ function History() {
const refreshSessionGames = useCallback(async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
setSessionGames(response.data.reverse());
// Reverse chronological order (most recent first) - create new array to avoid mutation
setSessionGames([...response.data].reverse());
} catch (err) {
if (!silent) {
console.error('Failed to load session games', err);
@@ -104,7 +104,8 @@ function History() {
const loadSessionGames = async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
setSessionGames(response.data);
// Reverse chronological order (most recent first) - create new array to avoid mutation
setSessionGames([...response.data].reverse());
if (!silent) {
setSelectedSession(sessionId);
}
@@ -319,7 +320,7 @@ function History() {
Games Played ({sessionGames.length})
</h3>
<div className="space-y-3">
{[...sessionGames].reverse().map((game, index) => (
{sessionGames.map((game, index) => (
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
<div className="flex justify-between items-start mb-2">
<div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
@@ -7,9 +7,11 @@ import PopularityBadge from '../components/PopularityBadge';
function Home() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
const [sessionGames, setSessionGames] = useState([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const loadSessionGames = useCallback(async (sessionId, silent = false) => {
try {
@@ -56,6 +58,19 @@ function Home() {
return () => clearInterval(interval);
}, [loadActiveSession]);
const handleCreateSession = async () => {
setCreating(true);
try {
await api.post('/sessions');
// Navigate to picker page after creating session
navigate('/picker');
} catch (error) {
console.error('Failed to create session:', error);
alert('Failed to create session. Please try again.');
setCreating(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
@@ -167,12 +182,13 @@ function Home() {
There is currently no game session in progress.
</p>
{isAuthenticated ? (
<Link
to="/picker"
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
<button
onClick={handleCreateSession}
disabled={creating}
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Start a New Session
</Link>
{creating ? 'Creating Session...' : 'Start a New Session'}
</button>
) : (
<p className="text-gray-500 dark:text-gray-400">
Admin access required to start a new session.
@@ -184,10 +200,18 @@ function Home() {
<div className="grid md:grid-cols-2 gap-6">
<Link
to="/history"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
>
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Session History
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
<span>Session History</span>
<svg
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</h3>
<p className="text-gray-600 dark:text-gray-300">
View past gaming sessions and the games that were played
@@ -197,10 +221,18 @@ function Home() {
{isAuthenticated && (
<Link
to="/manager"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
>
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Game Manager
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
<span>Game Manager</span>
<svg
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</h3>
<p className="text-gray-600 dark:text-gray-300">
Manage games, packs, and view statistics

View File

@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
darkMode: 'class',
content: [
"./index.html",

View File

@@ -1,8 +1,46 @@
const { defineConfig } = require('vite');
const react = require('@vitejs/plugin-react');
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { branding } from './src/config/branding.js';
module.exports = defineConfig({
plugins: [react()],
export default defineConfig({
plugins: [
react(),
{
name: 'html-transform',
transformIndexHtml(html) {
return html
.replace(/<title>.*?<\/title>/, `<title>${branding.app.name}</title>`)
.replace(
/<meta name="description" content=".*?"\/>/,
`<meta name="description" content="${branding.app.description}"/>`
)
.replace(
/<meta name="keywords" content=".*?"\/>/,
`<meta name="keywords" content="${branding.meta.keywords}"/>`
)
.replace(
/<meta name="author" content=".*?"\/>/,
`<meta name="author" content="${branding.meta.author}"/>`
)
.replace(
/<meta name="theme-color" content=".*?"\/>/,
`<meta name="theme-color" content="${branding.meta.themeColor}"/>`
)
.replace(
/<meta name="apple-mobile-web-app-title" content=".*?"\/>/,
`<meta name="apple-mobile-web-app-title" content="${branding.app.shortName}"/>`
)
.replace(
/<meta property="og:title" content=".*?"\/>/,
`<meta property="og:title" content="${branding.app.name}"/>`
)
.replace(
/<meta property="og:description" content=".*?"\/>/,
`<meta property="og:description" content="${branding.app.description}"/>`
);
},
},
],
server: {
host: '0.0.0.0',
port: 3000,