first release! 0.3.6
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,6 +15,9 @@ node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
# Generated files
|
||||
frontend/public/manifest.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
34
README.md
34
README.md
@@ -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
93
frontend/ICONS.md
Normal 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.
|
||||
|
||||
74
frontend/generate-manifest.js
Normal file
74
frontend/generate-manifest.js
Normal 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');
|
||||
|
||||
166
frontend/generate-png-icons.html
Normal file
166
frontend/generate-png-icons.html
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
3378
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
69
frontend/public/sw.js
Normal file
69
frontend/public/sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
97
frontend/src/components/InstallPrompt.jsx
Normal file
97
frontend/src/components/InstallPrompt.jsx
Normal 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;
|
||||
|
||||
71
frontend/src/components/SafariInstallPrompt.jsx
Normal file
71
frontend/src/components/SafariInstallPrompt.jsx
Normal 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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user