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

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