Compare commits
3 commits
9363f643f0
...
62281e1124
| Author | SHA1 | Date | |
|---|---|---|---|
|
62281e1124 |
|||
|
3d6081823c |
|||
|
73c7d3efed |
28 changed files with 1052 additions and 346 deletions
5
App.tsx
5
App.tsx
|
|
@ -137,10 +137,10 @@ function App() {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white relative">
|
||||
<div className="h-screen text-white relative overflow-hidden">
|
||||
<FloatingShapes />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="relative z-10 h-full">
|
||||
{gameState === 'LANDING' || gameState === 'GENERATING' ? (
|
||||
<Landing
|
||||
onGenerate={startQuizGen}
|
||||
|
|
@ -179,6 +179,7 @@ function App() {
|
|||
role={role}
|
||||
onStart={startGame}
|
||||
onEndGame={role === 'HOST' ? endGame : undefined}
|
||||
currentPlayerId={currentPlayerId}
|
||||
/>
|
||||
{auth.isAuthenticated && pendingQuizToSave && (
|
||||
<SaveQuizPrompt
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@ entries:
|
|||
attrs:
|
||||
name: kaboot-users
|
||||
|
||||
- id: kaboot-ai-access-group
|
||||
model: authentik_core.group
|
||||
identifiers:
|
||||
name: kaboot-ai-access
|
||||
attrs:
|
||||
name: kaboot-ai-access
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# OAUTH2/OIDC PROVIDER
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -234,8 +241,7 @@ entries:
|
|||
is_active: true
|
||||
groups:
|
||||
- !KeyOf kaboot-users-group
|
||||
# Note: Password must be set manually via UI or API after blueprint import
|
||||
# Run: docker compose exec authentik-server ak setpassword kaboottest
|
||||
- !KeyOf kaboot-ai-access-group
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SERVICE ACCOUNT (for API/automated testing)
|
||||
|
|
@ -253,5 +259,4 @@ entries:
|
|||
is_active: true
|
||||
groups:
|
||||
- !KeyOf kaboot-users-group
|
||||
# Note: App password must be created via UI or API after blueprint import
|
||||
# See docs/AUTHENTIK_SETUP.md for instructions
|
||||
- !KeyOf kaboot-ai-access-group
|
||||
|
|
|
|||
136
components/ApiKeyModal.tsx
Normal file
136
components/ApiKeyModal.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||
|
||||
interface ApiKeyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string | undefined;
|
||||
onSave: (key: string | undefined) => Promise<void>;
|
||||
saving: boolean;
|
||||
hasAIAccess: boolean;
|
||||
}
|
||||
|
||||
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
apiKey,
|
||||
onSave,
|
||||
saving,
|
||||
hasAIAccess,
|
||||
}) => {
|
||||
useBodyScrollLock(isOpen);
|
||||
const [localApiKey, setLocalApiKey] = useState(apiKey || '');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalApiKey(apiKey || '');
|
||||
}
|
||||
}, [isOpen, apiKey]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(localApiKey || undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900">
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<div className="p-2 bg-white/20 rounded-xl">
|
||||
<Key size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">Account Settings</h2>
|
||||
<p className="text-sm opacity-80">Manage your API access</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50">
|
||||
{hasAIAccess ? (
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<Key size={18} />
|
||||
Custom Gemini API Key
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Use your own API key for quiz generation. Leave empty to use the system key.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={localApiKey}
|
||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<Key size={32} className="mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 font-bold">API access not available</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Contact an administrator for access</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{hasAIAccess && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,31 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { LogIn, LogOut, User, Loader2 } from 'lucide-react';
|
||||
import { LogIn, LogOut, User, Loader2, Key, ChevronDown } from 'lucide-react';
|
||||
|
||||
export const AuthButton: React.FC = () => {
|
||||
interface AuthButtonProps {
|
||||
onAccountSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
export const AuthButton: React.FC<AuthButtonProps> = ({ onAccountSettingsClick }) => {
|
||||
const auth = useAuth();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
|
|
@ -24,20 +46,44 @@ export const AuthButton: React.FC = () => {
|
|||
|
||||
if (auth.isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex items-center gap-2 bg-white/10 px-3 py-2 rounded-xl hover:bg-white/20 transition cursor-pointer"
|
||||
>
|
||||
<User size={18} />
|
||||
<span className="font-bold text-sm">
|
||||
{auth.user?.profile.preferred_username || auth.user?.profile.name || 'User'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => auth.signoutRedirect()}
|
||||
className="p-2 bg-white/10 rounded-xl hover:bg-white/20 transition"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<ChevronDown size={16} className={`transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg py-2 z-50 border border-gray-100">
|
||||
{onAccountSettingsClick && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onAccountSettingsClick();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition"
|
||||
>
|
||||
<Key size={18} />
|
||||
Account Settings
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
auth.signoutRedirect();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const DisconnectedScreen: React.FC<DisconnectedScreenProps> = ({
|
|||
onGoHome,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||
<div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices } from 'lucide-react';
|
||||
import type { GameConfig } from '../types';
|
||||
|
||||
interface GameConfigPanelProps {
|
||||
|
|
@ -222,6 +222,16 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
|
|||
onChange={(v) => update({ hostParticipates: v })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Dices size={20} />}
|
||||
iconActive={config.randomNamesEnabled}
|
||||
label="Random Names"
|
||||
description="Assign fun two-word names to players"
|
||||
checked={config.randomNamesEnabled}
|
||||
onChange={(v) => update({ randomNamesEnabled: v })}
|
||||
tooltip="Players will be assigned random names like 'Brave Falcon' or 'Swift Tiger' instead of choosing their own nicknames."
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Flame size={20} />}
|
||||
iconActive={config.streakBonusEnabled}
|
||||
|
|
|
|||
|
|
@ -40,35 +40,34 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
return (
|
||||
<div className="flex flex-col h-screen max-h-screen overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 md:p-6">
|
||||
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
|
||||
<div className="flex justify-between items-center p-2 md:p-6 shrink-0">
|
||||
<div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
|
||||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</div>
|
||||
|
||||
{/* Whimsical Timer */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
|
||||
{timeLeftSeconds}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className={`bg-white ${timerTextColor} rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-2xl md:text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
|
||||
{timeLeftSeconds}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
|
||||
<div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
|
||||
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Area */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 text-center relative z-10">
|
||||
<div className="shrink-0 flex flex-col items-center justify-center p-2 md:p-8 text-center relative z-10">
|
||||
{question && (
|
||||
<motion.div
|
||||
key={question.id}
|
||||
initial={{ y: 20, opacity: 0, scale: 0.95 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="bg-white text-black p-8 md:p-12 rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-8 border-gray-200"
|
||||
className="bg-white text-black p-4 md:p-12 rounded-2xl md:rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-4 md:border-b-8 border-gray-200"
|
||||
>
|
||||
<h2 className="text-2xl md:text-5xl font-black text-[#333] font-display leading-tight">
|
||||
<h2 className="text-lg md:text-5xl font-black text-[#333] font-display leading-tight">
|
||||
{question.text}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
|
@ -76,7 +75,7 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Answer Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 md:gap-6 p-3 md:p-6 h-1/2 md:h-[45vh] pb-8 md:pb-12">
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-6 p-2 md:p-6 flex-1 min-h-0">
|
||||
{displayOptions.map((option, idx) => {
|
||||
const ShapeIcon = SHAPES[option.shape];
|
||||
const colorClass = COLORS[option.color];
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const HostReconnected: React.FC<HostReconnectedProps> = ({
|
|||
onEndGame,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||
<div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react';
|
||||
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings, Palette, Lock } from 'lucide-react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { AuthButton } from './AuthButton';
|
||||
import { QuizLibrary } from './QuizLibrary';
|
||||
import { DefaultConfigModal } from './DefaultConfigModal';
|
||||
import { PreferencesModal } from './PreferencesModal';
|
||||
import { ApiKeyModal } from './ApiKeyModal';
|
||||
import { useQuizLibrary } from '../hooks/useQuizLibrary';
|
||||
import { useUserConfig } from '../hooks/useUserConfig';
|
||||
import { useUserPreferences } from '../hooks/useUserPreferences';
|
||||
import type { Quiz, GameConfig } from '../types';
|
||||
|
||||
type GenerateMode = 'topic' | 'document';
|
||||
|
|
@ -22,7 +25,7 @@ interface LandingProps {
|
|||
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => {
|
||||
const auth = useAuth();
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('JOIN');
|
||||
const [generateMode, setGenerateMode] = useState<GenerateMode>('topic');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
|
@ -35,12 +38,18 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
const [useOcr, setUseOcr] = useState(false);
|
||||
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
|
||||
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
|
||||
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||
const [gameInfo, setGameInfo] = useState<{ randomNamesEnabled: boolean; quizTitle: string } | null>(null);
|
||||
const [checkingPin, setCheckingPin] = useState(false);
|
||||
|
||||
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/'));
|
||||
const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
|
||||
const showOcrOption = hasImageFile || hasDocumentFile;
|
||||
|
||||
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
||||
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
|
||||
const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey);
|
||||
|
||||
const {
|
||||
quizzes,
|
||||
|
|
@ -60,6 +69,30 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
}
|
||||
}, [libraryOpen, auth.isAuthenticated, fetchQuizzes]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkGamePin = async () => {
|
||||
if (pin.trim().length === 6) {
|
||||
setCheckingPin(true);
|
||||
try {
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||
const response = await fetch(`${backendUrl}/api/games/${pin.trim()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGameInfo({ randomNamesEnabled: data.randomNamesEnabled, quizTitle: data.quizTitle });
|
||||
} else {
|
||||
setGameInfo(null);
|
||||
}
|
||||
} catch {
|
||||
setGameInfo(null);
|
||||
}
|
||||
setCheckingPin(false);
|
||||
} else {
|
||||
setGameInfo(null);
|
||||
}
|
||||
};
|
||||
checkGamePin();
|
||||
}, [pin]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
|
|
@ -104,7 +137,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
setUseOcr(false);
|
||||
};
|
||||
|
||||
const canGenerate = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
||||
const canGenerateInput = generateMode === 'topic' ? topic.trim() : selectedFiles.length > 0;
|
||||
const canGenerate = canUseAI && canGenerateInput;
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -120,7 +154,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
const handleJoinSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pin.trim() && name.trim()) onJoin(pin, name);
|
||||
if (pin.trim() && (gameInfo?.randomNamesEnabled || name.trim())) {
|
||||
onJoin(pin, name.trim() || 'Player');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadQuiz = async (id: string) => {
|
||||
|
|
@ -137,37 +173,46 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
|
||||
<div className="flex flex-col items-center justify-center h-screen p-4 text-center relative overflow-hidden">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingDefaultConfig(defaultConfig);
|
||||
setDefaultConfigOpen(true);
|
||||
}}
|
||||
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
|
||||
title="Default Game Settings"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPreferencesOpen(true)}
|
||||
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
|
||||
title="Preferences"
|
||||
>
|
||||
<Palette size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingDefaultConfig(defaultConfig);
|
||||
setDefaultConfigOpen(true);
|
||||
}}
|
||||
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
|
||||
title="Default Game Settings"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<AuthButton />
|
||||
<AuthButton onAccountSettingsClick={() => setAccountSettingsOpen(true)} />
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.5 }}
|
||||
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
|
||||
className="bg-white text-gray-900 p-6 md:p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
||||
>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-theme-primary p-4 rounded-3xl rotate-3 shadow-lg">
|
||||
<BrainCircuit size={48} className="text-white" />
|
||||
<div className="flex justify-center mb-4 md:mb-6">
|
||||
<div className="bg-theme-primary p-3 md:p-4 rounded-2xl md:rounded-3xl rotate-3 shadow-lg">
|
||||
<BrainCircuit size={36} className="text-white md:w-12 md:h-12" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl font-black mb-2 text-theme-primary tracking-tight">Kaboot</h1>
|
||||
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black mb-1 md:mb-2 text-theme-primary tracking-tight">Kaboot</h1>
|
||||
<p className="text-gray-500 font-bold mb-4 md:mb-6">The AI Quiz Party</p>
|
||||
|
||||
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8">
|
||||
<div className="flex bg-gray-100 p-2 rounded-2xl mb-6 md:mb-8">
|
||||
<button
|
||||
onClick={() => setMode('HOST')}
|
||||
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-theme-primary scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
|
|
@ -184,209 +229,226 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
{mode === 'HOST' ? (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<div className="flex bg-gray-100 p-1.5 rounded-xl mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGenerateMode('topic')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
||||
generateMode === 'topic'
|
||||
? 'bg-white shadow-sm text-theme-primary'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
{!auth.isAuthenticated ? (
|
||||
<div className="text-center py-6">
|
||||
<Lock size={32} className="mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 font-bold mb-4">Sign in to host quizzes</p>
|
||||
<button
|
||||
onClick={() => auth.signinRedirect()}
|
||||
className="bg-theme-primary text-white py-3 px-6 rounded-2xl font-black hover:opacity-90 transition-all"
|
||||
>
|
||||
<Sparkles size={16} /> Topic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGenerateMode('document')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
||||
generateMode === 'document'
|
||||
? 'bg-white shadow-sm text-theme-primary'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} /> Document
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{generateMode === 'topic' ? (
|
||||
<motion.div
|
||||
key="topic"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a topic (e.g. 'Space')"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="document"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
|
||||
isDragging
|
||||
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
|
||||
: selectedFiles.length > 0
|
||||
? 'border-theme-primary/50 bg-theme-primary/5'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
|
||||
<p className="text-sm font-bold text-gray-600">
|
||||
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearAllFiles();
|
||||
}}
|
||||
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1.5">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
|
||||
<div className="flex items-center gap-2 overflow-hidden text-left">
|
||||
<div className="p-1.5 bg-theme-primary/10 rounded">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<Image size={14} className="text-theme-primary" />
|
||||
) : (
|
||||
<FileText size={14} className="text-theme-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOcrOption && (
|
||||
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useOcr}
|
||||
onChange={(e) => setUseOcr(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanText size={18} className="text-gray-500" />
|
||||
<span className="text-sm font-bold text-gray-600">
|
||||
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
|
||||
<span>Questions</span>
|
||||
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="30"
|
||||
value={questionCount}
|
||||
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
||||
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canGenerate}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
) : (
|
||||
<>
|
||||
{canUseAI && (
|
||||
<>
|
||||
<BrainCircuit size={24} />
|
||||
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<div className="flex bg-gray-100 p-1.5 rounded-xl mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGenerateMode('topic')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
||||
generateMode === 'topic'
|
||||
? 'bg-white shadow-sm text-theme-primary'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={16} /> Topic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGenerateMode('document')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center gap-1.5 ${
|
||||
generateMode === 'document'
|
||||
? 'bg-white shadow-sm text-theme-primary'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} /> Document
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{generateMode === 'topic' ? (
|
||||
<motion.div
|
||||
key="topic"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a topic (e.g. 'Space')"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none transition-all placeholder:font-medium text-center"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="document"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
className={`border-2 border-dashed rounded-2xl p-4 text-center cursor-pointer transition-all ${
|
||||
isDragging
|
||||
? 'border-theme-primary bg-theme-primary/5 scale-[1.02]'
|
||||
: selectedFiles.length > 0
|
||||
? 'border-theme-primary/50 bg-theme-primary/5'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".pdf,.txt,.md,.docx,.pptx,.xlsx,.odt,.odp,.ods,.rtf,.jpg,.jpeg,.png,.gif,.webp"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Upload className={`mx-auto ${isDragging ? 'text-theme-primary' : 'text-gray-400'}`} size={28} />
|
||||
<p className="text-sm font-bold text-gray-600">
|
||||
{isDragging ? 'Drop files here' : 'Drop files or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PDF, DOCX, PPTX, XLSX, TXT, Images</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-gray-600">{selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearAllFiles();
|
||||
}}
|
||||
className="text-xs font-bold text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1.5">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-white p-2 rounded-lg shadow-sm">
|
||||
<div className="flex items-center gap-2 overflow-hidden text-left">
|
||||
<div className="p-1.5 bg-theme-primary/10 rounded">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<Image size={14} className="text-theme-primary" />
|
||||
) : (
|
||||
<FileText size={14} className="text-theme-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 truncate max-w-[180px]">{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOcrOption && (
|
||||
<label className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useOcr}
|
||||
onChange={(e) => setUseOcr(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-6 bg-gray-300 rounded-full peer-checked:bg-theme-primary transition-colors"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-4 transition-transform"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanText size={18} className="text-gray-500" />
|
||||
<span className="text-sm font-bold text-gray-600">
|
||||
{hasImageFile && !hasDocumentFile ? 'Extract text with OCR' : 'OCR embedded images'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between items-center text-sm font-bold text-gray-500">
|
||||
<span>Questions</span>
|
||||
<span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-lg text-xs">{questionCount}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="30"
|
||||
value={questionCount}
|
||||
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
||||
className="w-full accent-theme-primary h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canGenerate}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<BrainCircuit size={24} />
|
||||
{generateMode === 'document' ? 'Generate from Document' : 'Generate Quiz'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative flex py-2 items-center opacity-50">
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative flex py-2 items-center opacity-50">
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCreateManual}
|
||||
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateManual}
|
||||
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<button
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<BookOpen size={20} /> My Quizzes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
className="w-full bg-gray-100 text-gray-600 py-3 rounded-2xl text-lg font-black hover:bg-gray-200 shadow-[0_4px_0_#d1d5db] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<BookOpen size={20} /> My Quizzes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -398,19 +460,27 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
onChange={(e) => setPin(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nickname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center"
|
||||
/>
|
||||
{gameInfo?.randomNamesEnabled ? (
|
||||
<div className="p-4 bg-theme-primary/10 rounded-2xl border-2 border-theme-primary/20">
|
||||
<p className="text-theme-primary font-bold text-center">
|
||||
You'll get a random name!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nickname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-theme-primary focus:ring-4 focus:ring-theme-primary/20 outline-none text-center"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!pin.trim() || !name.trim()}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||
disabled={!pin.trim() || (!gameInfo?.randomNamesEnabled && !name.trim()) || checkingPin}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play fill="currentColor" /> Join Game
|
||||
{checkingPin ? <Loader2 className="animate-spin" /> : <Play fill="currentColor" />} Join Game
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -452,6 +522,26 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
}}
|
||||
saving={savingConfig}
|
||||
/>
|
||||
|
||||
<PreferencesModal
|
||||
isOpen={preferencesOpen}
|
||||
onClose={() => setPreferencesOpen(false)}
|
||||
preferences={preferences}
|
||||
onSave={savePreferences}
|
||||
onPreview={applyColorScheme}
|
||||
saving={savingPrefs}
|
||||
/>
|
||||
|
||||
<ApiKeyModal
|
||||
isOpen={accountSettingsOpen}
|
||||
onClose={() => setAccountSettingsOpen(false)}
|
||||
apiKey={preferences.geminiApiKey}
|
||||
onSave={async (key) => {
|
||||
await savePreferences({ ...preferences, geminiApiKey: key });
|
||||
}}
|
||||
saving={savingPrefs}
|
||||
hasAIAccess={hasAIAccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,23 +11,25 @@ interface LobbyProps {
|
|||
role: 'HOST' | 'CLIENT';
|
||||
onStart: () => void;
|
||||
onEndGame?: () => void;
|
||||
currentPlayerId?: string | null;
|
||||
}
|
||||
|
||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame }) => {
|
||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId }) => {
|
||||
const isHost = role === 'HOST';
|
||||
const realPlayers = players.filter(p => p.id !== 'host');
|
||||
const currentPlayer = currentPlayerId ? players.find(p => p.id === currentPlayerId) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen p-6">
|
||||
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-6 rounded-[2rem] backdrop-blur-md mb-8 gap-6 border-4 border-white/20 shadow-xl">
|
||||
<div className="flex flex-col h-screen p-4 md:p-6 overflow-hidden">
|
||||
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-4 md:p-6 rounded-2xl md:rounded-[2rem] backdrop-blur-md mb-4 md:mb-8 gap-3 md:gap-6 border-4 border-white/20 shadow-xl shrink-0">
|
||||
<div className="flex flex-col items-center md:items-start">
|
||||
<span className="text-white/80 font-bold uppercase tracking-widest text-sm mb-1">Game PIN</span>
|
||||
<div className="text-5xl md:text-6xl font-black bg-white text-theme-primary px-8 py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
||||
<span className="text-white/80 font-bold uppercase tracking-widest text-xs md:text-sm mb-1">Game PIN</span>
|
||||
<div className="text-4xl md:text-6xl font-black bg-white text-theme-primary px-6 md:px-8 py-1 md:py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
||||
{gamePin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-center hidden md:block">
|
||||
<div className="text-3xl font-black font-display mb-2">{quizTitle}</div>
|
||||
<div className="inline-flex items-center gap-2 bg-[#00000040] px-4 py-1 rounded-full">
|
||||
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
||||
|
|
@ -35,23 +37,23 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/20 px-6 py-3 rounded-2xl font-black text-2xl flex flex-col items-center min-w-[120px]">
|
||||
<div className="bg-white/20 px-4 md:px-6 py-2 md:py-3 rounded-xl md:rounded-2xl font-black text-xl md:text-2xl flex flex-col items-center min-w-[100px] md:min-w-[120px]">
|
||||
<span>{realPlayers.length}</span>
|
||||
<span className="text-sm font-bold opacity-80 uppercase">Players</span>
|
||||
<span className="text-xs md:text-sm font-bold opacity-80 uppercase">Players</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center">
|
||||
<main className="flex-1 flex flex-col items-center justify-center overflow-hidden min-h-0">
|
||||
{isHost ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4 justify-center w-full max-w-6xl mb-12 min-h-[200px] content-start">
|
||||
<div className="flex flex-wrap gap-3 md:gap-4 justify-center w-full max-w-6xl pb-24 md:pb-28 overflow-y-auto content-start">
|
||||
<AnimatePresence>
|
||||
{realPlayers.length === 0 && (
|
||||
<div className="flex flex-col items-center opacity-60 mt-12">
|
||||
<div className="bg-white/10 p-6 rounded-full mb-4 animate-bounce">
|
||||
<Sparkles size={48} />
|
||||
<div className="flex flex-col items-center opacity-60 mt-8 md:mt-12">
|
||||
<div className="bg-white/10 p-4 md:p-6 rounded-full mb-4 animate-bounce">
|
||||
<Sparkles size={36} className="md:w-12 md:h-12" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold font-display">Waiting for players to join...</div>
|
||||
<div className="text-xl md:text-3xl font-bold font-display text-center px-4">Waiting for players to join...</div>
|
||||
</div>
|
||||
)}
|
||||
{realPlayers.map((player) => (
|
||||
|
|
@ -60,7 +62,7 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
|||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="bg-white text-black px-6 py-3 rounded-full font-black text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-3 border-b-4 border-gray-200"
|
||||
className="bg-white text-black px-4 md:px-6 py-2 md:py-3 rounded-full font-black text-base md:text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-2 md:gap-3 border-b-4 border-gray-200"
|
||||
>
|
||||
<PlayerAvatar seed={player.avatarSeed} size={20} />
|
||||
{player.name}
|
||||
|
|
@ -72,38 +74,45 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
|||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="fixed bottom-8 flex gap-4"
|
||||
className="fixed bottom-4 md:bottom-8 flex gap-2 md:gap-4 px-4"
|
||||
>
|
||||
{onEndGame && (
|
||||
<button
|
||||
onClick={onEndGame}
|
||||
className="bg-white/20 text-white px-8 py-5 rounded-full text-xl font-bold hover:bg-white/30 active:scale-95 transition-all flex items-center gap-2"
|
||||
className="bg-white/20 text-white px-4 md:px-8 py-3 md:py-5 rounded-full text-base md:text-xl font-bold hover:bg-white/30 active:scale-95 transition-all flex items-center gap-2"
|
||||
>
|
||||
<X size={24} />
|
||||
End Game
|
||||
<X size={20} className="md:w-6 md:h-6" />
|
||||
<span className="hidden md:inline">End Game</span>
|
||||
<span className="md:hidden">End</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={realPlayers.length === 0}
|
||||
className="bg-white text-theme-primary px-16 py-5 rounded-full text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2"
|
||||
className="bg-white text-theme-primary px-8 md:px-16 py-3 md:py-5 rounded-full text-xl md:text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2"
|
||||
>
|
||||
Start Game
|
||||
Start
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-4 md:p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.6 }}
|
||||
className="bg-white text-theme-primary p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-8"
|
||||
className="bg-white p-6 md:p-8 rounded-2xl md:rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-4 md:mb-8"
|
||||
>
|
||||
<User size={80} strokeWidth={2.5} />
|
||||
{currentPlayer ? (
|
||||
<PlayerAvatar seed={currentPlayer.avatarSeed} size={60} className="md:w-20 md:h-20" />
|
||||
) : (
|
||||
<User size={60} strokeWidth={2.5} className="text-theme-primary md:w-20 md:h-20" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h2 className="text-5xl font-black mb-4 font-display">You're in!</h2>
|
||||
<p className="text-2xl font-bold opacity-80">See your name on the big screen?</p>
|
||||
<h2 className="text-3xl md:text-5xl font-black mb-2 md:mb-4 font-display">
|
||||
{currentPlayer?.name || "You're in!"}
|
||||
</h2>
|
||||
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<h1 className="text-6xl font-black text-white mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1>
|
||||
<div className="h-screen flex flex-col items-center justify-center p-4 overflow-hidden">
|
||||
<h1 className="text-4xl md:text-6xl font-black text-white mb-6 md:mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1>
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8 mb-12 w-full max-w-4xl h-96">
|
||||
<div className="flex items-end justify-center gap-2 md:gap-8 mb-6 md:mb-12 w-full max-w-4xl h-48 md:h-96 px-2">
|
||||
{/* Second Place */}
|
||||
{second && (
|
||||
<motion.div
|
||||
|
|
|
|||
130
components/PreferencesModal.tsx
Normal file
130
components/PreferencesModal.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Palette, Loader2, Check } from 'lucide-react';
|
||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||
import type { UserPreferences } from '../types';
|
||||
import { COLOR_SCHEMES } from '../types';
|
||||
|
||||
interface PreferencesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
preferences: UserPreferences;
|
||||
onSave: (prefs: UserPreferences) => Promise<void>;
|
||||
onPreview: (schemeId: string) => void;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export const PreferencesModal: React.FC<PreferencesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
preferences,
|
||||
onSave,
|
||||
onPreview,
|
||||
saving,
|
||||
}) => {
|
||||
useBodyScrollLock(isOpen);
|
||||
const [localPrefs, setLocalPrefs] = useState<UserPreferences>(preferences);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleColorSelect = (schemeId: string) => {
|
||||
setLocalPrefs(prev => ({ ...prev, colorScheme: schemeId }));
|
||||
onPreview(schemeId);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(localPrefs);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onPreview(preferences.colorScheme);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<div className="p-2 bg-white/20 rounded-xl">
|
||||
<Palette size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">Color Scheme</h2>
|
||||
<p className="text-sm opacity-80">Customize your theme</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-gray-50">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{COLOR_SCHEMES.map((scheme) => (
|
||||
<button
|
||||
key={scheme.id}
|
||||
onClick={() => handleColorSelect(scheme.id)}
|
||||
className={`relative aspect-square rounded-xl transition-all ${
|
||||
localPrefs.colorScheme === scheme.id
|
||||
? 'ring-2 ring-offset-2 ring-gray-800 scale-105'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{ background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.primaryDarker} 100%)` }}
|
||||
title={scheme.name}
|
||||
>
|
||||
{localPrefs.colorScheme === scheme.id && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Check size={24} className="text-white drop-shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-3 text-center">
|
||||
{COLOR_SCHEMES.find(s => s.id === localPrefs.colorScheme)?.name || 'Select a color'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 flex gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -60,8 +60,8 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
|
||||
<div className="h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center overflow-hidden">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white flex-1 min-h-0 flex flex-col">
|
||||
<div className="bg-theme-primary p-8 text-white flex justify-between items-center relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl font-black font-display">Create Quiz</h2>
|
||||
|
|
@ -79,7 +79,7 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
|
|||
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="p-6 md:p-8 space-y-6 md:space-y-8 flex-1 min-h-0 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -153,8 +153,8 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
|
||||
<div className="h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center overflow-hidden">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white flex-1 min-h-0 flex flex-col">
|
||||
<div className="bg-theme-primary p-6 text-white relative overflow-hidden">
|
||||
<div className="relative z-10 flex items-center justify-between gap-4">
|
||||
<button
|
||||
|
|
@ -206,7 +206,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
|
|||
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-700">Questions</h2>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -93,39 +93,29 @@ const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
|
|||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: baseDelay, duration: 0.4 }}
|
||||
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-3"
|
||||
className="flex flex-col gap-2 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between md:justify-start gap-3 w-full md:w-48 md:shrink-0">
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PlayerAvatar seed={player.avatarSeed} size={32} />
|
||||
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<span className="font-black text-2xl font-display">
|
||||
<AnimatedNumber value={getDisplayScore()} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-black text-2xl font-display">
|
||||
<AnimatedNumber value={getDisplayScore()} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:flex-1 flex items-center gap-4">
|
||||
<div className="flex-1 h-12 bg-gray-100 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: player.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block w-20 text-right">
|
||||
<span className="font-black text-2xl font-display">
|
||||
<AnimatedNumber value={getDisplayScore()} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-10 md:h-12 bg-gray-100 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: player.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto md:min-w-[280px] justify-start">
|
||||
<div className="flex flex-wrap items-center gap-2 w-full justify-start">
|
||||
<AnimatePresence>
|
||||
{breakdown === null && (
|
||||
<motion.span
|
||||
|
|
@ -200,16 +190,16 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
|
|||
...p,
|
||||
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
||||
}));
|
||||
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
|
||||
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-4 md:p-8">
|
||||
<header className="text-center mb-4 md:mb-8">
|
||||
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
|
||||
<header className="text-center mb-4 md:mb-8 shrink-0">
|
||||
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{sortedPlayers.map((player, index) => (
|
||||
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
|
||||
|
|
@ -217,7 +207,7 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto">
|
||||
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto shrink-0">
|
||||
{isHost ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface WaitingToRejoinProps {
|
|||
|
||||
export const WaitingToRejoin: React.FC<WaitingToRejoinProps> = ({ playerName, score }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||
<div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Questi
|
|||
import { generateQuiz } from '../services/geminiService';
|
||||
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
|
||||
import { Peer, DataConnection } from 'peerjs';
|
||||
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||
const SESSION_STORAGE_KEY = 'kaboot_session';
|
||||
|
|
@ -73,6 +74,7 @@ export const useGame = () => {
|
|||
const hostSecretRef = useRef<string | null>(null);
|
||||
const gameStateRef = useRef<GameState>("LANDING");
|
||||
const firstCorrectPlayerIdRef = useRef<string | null>(null);
|
||||
const currentCorrectShapeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
||||
useEffect(() => { playersRef.current = players; }, [players]);
|
||||
|
|
@ -83,9 +85,19 @@ export const useGame = () => {
|
|||
useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]);
|
||||
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
|
||||
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
|
||||
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
|
||||
|
||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||
|
||||
const generateRandomName = (): string => {
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, animals],
|
||||
separator: ' ',
|
||||
style: 'capital',
|
||||
length: 2,
|
||||
});
|
||||
};
|
||||
|
||||
const syncGameState = useCallback(async () => {
|
||||
if (!gamePinRef.current || !hostSecretRef.current) return;
|
||||
if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return;
|
||||
|
|
@ -510,13 +522,23 @@ export const useGame = () => {
|
|||
allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect }))
|
||||
});
|
||||
|
||||
let assignedName = payload.name;
|
||||
if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) {
|
||||
assignedName = generateRandomName();
|
||||
}
|
||||
|
||||
let updatedPlayers = playersRef.current;
|
||||
let newPlayer: Player | null = null;
|
||||
|
||||
if (reconnectedPlayer) {
|
||||
setPlayers(prev => prev.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p));
|
||||
updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p);
|
||||
setPlayers(updatedPlayers);
|
||||
assignedName = reconnectedPlayer.name;
|
||||
} else if (!playersRef.current.find(p => p.id === conn.peer)) {
|
||||
const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
|
||||
const newPlayer: Player = {
|
||||
newPlayer = {
|
||||
id: conn.peer,
|
||||
name: payload.name,
|
||||
name: assignedName,
|
||||
score: 0,
|
||||
previousScore: 0,
|
||||
streak: 0,
|
||||
|
|
@ -527,7 +549,8 @@ export const useGame = () => {
|
|||
avatarSeed: Math.random(),
|
||||
color: PLAYER_COLORS[colorIndex]
|
||||
};
|
||||
setPlayers(prev => [...prev, newPlayer]);
|
||||
updatedPlayers = [...playersRef.current, newPlayer];
|
||||
setPlayers(updatedPlayers);
|
||||
}
|
||||
|
||||
const currentState = gameStateRef.current;
|
||||
|
|
@ -538,7 +561,7 @@ export const useGame = () => {
|
|||
const welcomePayload: any = {
|
||||
playerId: conn.peer,
|
||||
quizTitle: currentQuiz?.title || 'Kaboot',
|
||||
players: playersRef.current,
|
||||
players: updatedPlayers,
|
||||
gameState: currentState,
|
||||
currentQuestionIndex: currentIndex,
|
||||
totalQuestions: currentQuiz?.questions.length || 0,
|
||||
|
|
@ -548,6 +571,7 @@ export const useGame = () => {
|
|||
hasAnswered: false,
|
||||
lastAnswerCorrect: null,
|
||||
selectedShape: null,
|
||||
assignedName,
|
||||
};
|
||||
|
||||
if (currentQuestion) {
|
||||
|
|
@ -726,6 +750,48 @@ export const useGame = () => {
|
|||
|
||||
const endQuestion = () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
|
||||
const unansweredPlayers = playersRef.current.filter(p => p.lastAnswerCorrect === null);
|
||||
if (unansweredPlayers.length > 0) {
|
||||
const updatedPlayers = playersRef.current.map(p => {
|
||||
if (p.lastAnswerCorrect !== null) return p;
|
||||
|
||||
const playerRank = getPlayerRank(p.id, playersRef.current);
|
||||
const breakdown = calculatePointsWithBreakdown({
|
||||
isCorrect: false,
|
||||
timeLeftMs: 0,
|
||||
questionTimeMs: QUESTION_TIME_MS,
|
||||
streak: 0,
|
||||
playerRank,
|
||||
isFirstCorrect: false,
|
||||
config: gameConfigRef.current,
|
||||
});
|
||||
const newScore = Math.max(0, p.score + breakdown.total);
|
||||
|
||||
if (p.id === 'host') {
|
||||
setLastPointsEarned(breakdown.total);
|
||||
setLastAnswerCorrect(false);
|
||||
setCurrentPlayerScore(newScore);
|
||||
setCurrentStreak(0);
|
||||
} else {
|
||||
const conn = connectionsRef.current.get(p.id);
|
||||
if (conn?.open) {
|
||||
conn.send({ type: 'RESULT', payload: { isCorrect: false, scoreAdded: breakdown.total, newScore, breakdown } });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
score: newScore,
|
||||
previousScore: p.score,
|
||||
streak: 0,
|
||||
lastAnswerCorrect: false,
|
||||
pointsBreakdown: breakdown,
|
||||
};
|
||||
});
|
||||
setPlayers(updatedPlayers);
|
||||
}
|
||||
|
||||
setGameState('REVEAL');
|
||||
broadcast({ type: 'TIME_UP', payload: {} });
|
||||
};
|
||||
|
|
@ -915,6 +981,13 @@ export const useGame = () => {
|
|||
if (payload.players) {
|
||||
setPlayers(payload.players);
|
||||
}
|
||||
if (payload.assignedName) {
|
||||
setCurrentPlayerName(payload.assignedName);
|
||||
const session = getStoredSession();
|
||||
if (session) {
|
||||
storeSession({ ...session, playerName: payload.assignedName });
|
||||
}
|
||||
}
|
||||
if (payload.selectedShape && payload.options) {
|
||||
const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape);
|
||||
if (matchedOption) {
|
||||
|
|
@ -1087,8 +1160,9 @@ export const useGame = () => {
|
|||
} else {
|
||||
const option = arg as AnswerOption;
|
||||
setSelectedOption(option);
|
||||
const isCorrect = option.shape === currentCorrectShape;
|
||||
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape, isCorrect });
|
||||
// Use ref to avoid stale closure - currentCorrectShape state may not be updated yet
|
||||
const isCorrect = option.shape === currentCorrectShapeRef.current;
|
||||
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape: currentCorrectShapeRef.current, isCorrect });
|
||||
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect, selectedShape: option.shape } });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
94
hooks/useUserPreferences.ts
Normal file
94
hooks/useUserPreferences.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
||||
import type { UserPreferences } from '../types';
|
||||
import { COLOR_SCHEMES } from '../types';
|
||||
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
colorScheme: 'blue',
|
||||
};
|
||||
|
||||
export const applyColorScheme = (schemeId: string) => {
|
||||
const scheme = COLOR_SCHEMES.find(s => s.id === schemeId) || COLOR_SCHEMES[0];
|
||||
document.documentElement.style.setProperty('--theme-primary', scheme.primary);
|
||||
document.documentElement.style.setProperty('--theme-primary-dark', scheme.primaryDark);
|
||||
document.documentElement.style.setProperty('--theme-primary-darker', scheme.primaryDarker);
|
||||
};
|
||||
|
||||
interface UseUserPreferencesReturn {
|
||||
preferences: UserPreferences;
|
||||
hasAIAccess: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
fetchPreferences: () => Promise<void>;
|
||||
savePreferences: (prefs: UserPreferences) => Promise<void>;
|
||||
applyColorScheme: (schemeId: string) => void;
|
||||
}
|
||||
|
||||
export const useUserPreferences = (): UseUserPreferencesReturn => {
|
||||
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
|
||||
const [hasAIAccess, setHasAIAccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchPreferences = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await authFetch('/api/users/me/preferences');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const prefs: UserPreferences = {
|
||||
colorScheme: data.colorScheme || 'blue',
|
||||
geminiApiKey: data.geminiApiKey || undefined,
|
||||
};
|
||||
setPreferences(prefs);
|
||||
setHasAIAccess(data.hasAIAccess || false);
|
||||
applyColorScheme(prefs.colorScheme);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch, isAuthenticated]);
|
||||
|
||||
const savePreferences = useCallback(async (prefs: UserPreferences) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await authFetch('/api/users/me/preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preferences');
|
||||
}
|
||||
|
||||
setPreferences(prefs);
|
||||
applyColorScheme(prefs.colorScheme);
|
||||
toast.success('Preferences saved!');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
toast.error(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreferences();
|
||||
}, [fetchPreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
hasAIAccess,
|
||||
loading,
|
||||
saving,
|
||||
fetchPreferences,
|
||||
savePreferences,
|
||||
applyColorScheme,
|
||||
};
|
||||
};
|
||||
11
index.css
11
index.css
|
|
@ -1,16 +1,11 @@
|
|||
html, body, #root {
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
height: 100dvh !important;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100dvh;
|
||||
min-height: 100dvh !important;
|
||||
}
|
||||
|
||||
.max-h-screen {
|
||||
max-height: 100dvh;
|
||||
max-height: 100dvh !important;
|
||||
}
|
||||
|
|
|
|||
15
index.html
15
index.html
|
|
@ -33,8 +33,19 @@
|
|||
font-family: 'Montserrat', sans-serif;
|
||||
background: linear-gradient(180deg, var(--theme-primary) 0%, var(--theme-primary-darker) 100%);
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, .font-display {
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-oidc-context": "^3.2.0",
|
||||
"recharts": "^3.6.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -4160,6 +4161,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unique-names-generator": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
|
||||
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-oidc-context": "^3.2.0",
|
||||
"recharts": "^3.6.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,18 @@ const runMigrations = () => {
|
|||
db.exec("ALTER TABLE game_sessions ADD COLUMN first_correct_player_id TEXT");
|
||||
console.log("Migration: Added first_correct_player_id to game_sessions");
|
||||
}
|
||||
|
||||
const userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
|
||||
if (!hasColorScheme) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
|
||||
console.log("Migration: Added color_scheme to users");
|
||||
}
|
||||
const hasGeminiKey = userTableInfo2.some(col => col.name === "gemini_api_key");
|
||||
if (!hasGeminiKey) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
|
||||
console.log("Migration: Added gemini_api_key to users");
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
display_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
default_game_config TEXT
|
||||
default_game_config TEXT,
|
||||
color_scheme TEXT DEFAULT 'blue',
|
||||
gemini_api_key TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quizzes (
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface AuthenticatedUser {
|
|||
preferred_username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
|
|
@ -74,6 +75,7 @@ export function requireAuth(
|
|||
preferred_username: payload.preferred_username || payload.sub!,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
groups: payload.groups || [],
|
||||
};
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ router.get('/:pin', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const gameConfig = JSON.parse(session.game_config);
|
||||
res.json({
|
||||
pin: session.pin,
|
||||
hostPeerId: session.host_peer_id,
|
||||
|
|
@ -81,6 +82,7 @@ router.get('/:pin', (req: Request, res: Response) => {
|
|||
currentQuestionIndex: session.current_question_index,
|
||||
quizTitle: JSON.parse(session.quiz_data).title,
|
||||
playerCount: JSON.parse(session.players_data).length,
|
||||
randomNamesEnabled: gameConfig.randomNamesEnabled || false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error getting game session:', err);
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ router.use(requireAuth);
|
|||
|
||||
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin
|
||||
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
|
||||
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
|
||||
created_at as createdAt, last_login as lastLogin
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(req.user!.sub) as Record<string, unknown> | undefined;
|
||||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
|
||||
if (!user) {
|
||||
res.json({
|
||||
id: req.user!.sub,
|
||||
|
|
@ -20,6 +25,9 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
email: req.user!.email,
|
||||
displayName: req.user!.name,
|
||||
defaultGameConfig: null,
|
||||
colorScheme: 'blue',
|
||||
geminiApiKey: null,
|
||||
hasAIAccess,
|
||||
createdAt: null,
|
||||
lastLogin: null,
|
||||
isNew: true,
|
||||
|
|
@ -36,7 +44,12 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
}
|
||||
}
|
||||
|
||||
res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false });
|
||||
res.json({
|
||||
...user,
|
||||
defaultGameConfig: parsedConfig,
|
||||
hasAIAccess,
|
||||
isNew: false
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
||||
|
|
@ -64,4 +77,47 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
|
|||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = db.prepare(`
|
||||
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
|
||||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
|
||||
res.json({
|
||||
colorScheme: user?.colorScheme || 'blue',
|
||||
geminiApiKey: user?.geminiApiKey || null,
|
||||
hasAIAccess,
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||
const { colorScheme, geminiApiKey } = req.body;
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
color_scheme = ?,
|
||||
gemini_api_key = ?,
|
||||
last_login = CURRENT_TIMESTAMP
|
||||
`);
|
||||
|
||||
upsertUser.run(
|
||||
req.user!.sub,
|
||||
req.user!.preferred_username,
|
||||
req.user!.email || null,
|
||||
req.user!.name || null,
|
||||
colorScheme || 'blue',
|
||||
geminiApiKey || null,
|
||||
colorScheme || 'blue',
|
||||
geminiApiKey || null
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
31
types.ts
31
types.ts
|
|
@ -13,6 +13,34 @@ export type GameState =
|
|||
| 'WAITING_TO_REJOIN'
|
||||
| 'HOST_RECONNECTED';
|
||||
|
||||
export interface ColorScheme {
|
||||
id: string;
|
||||
name: string;
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
primaryDarker: string;
|
||||
}
|
||||
|
||||
export const COLOR_SCHEMES: ColorScheme[] = [
|
||||
{ id: 'blue', name: 'Ocean Blue', primary: '#2563eb', primaryDark: '#1e40af', primaryDarker: '#1e3a5f' },
|
||||
{ id: 'purple', name: 'Royal Purple', primary: '#7c3aed', primaryDark: '#5b21b6', primaryDarker: '#3b1a5f' },
|
||||
{ id: 'pink', name: 'Hot Pink', primary: '#db2777', primaryDark: '#9d174d', primaryDarker: '#5f1a3b' },
|
||||
{ id: 'red', name: 'Ruby Red', primary: '#dc2626', primaryDark: '#991b1b', primaryDarker: '#5f1a1a' },
|
||||
{ id: 'orange', name: 'Sunset Orange', primary: '#ea580c', primaryDark: '#c2410c', primaryDarker: '#5f2a1a' },
|
||||
{ id: 'yellow', name: 'Golden Yellow', primary: '#ca8a04', primaryDark: '#a16207', primaryDarker: '#5f4a1a' },
|
||||
{ id: 'green', name: 'Emerald Green', primary: '#16a34a', primaryDark: '#15803d', primaryDarker: '#1a5f3b' },
|
||||
{ id: 'teal', name: 'Teal', primary: '#0d9488', primaryDark: '#0f766e', primaryDarker: '#1a5f5a' },
|
||||
{ id: 'cyan', name: 'Cyan', primary: '#0891b2', primaryDark: '#0e7490', primaryDarker: '#1a4a5f' },
|
||||
{ id: 'indigo', name: 'Indigo', primary: '#4f46e5', primaryDark: '#4338ca', primaryDarker: '#2a2a5f' },
|
||||
{ id: 'slate', name: 'Slate', primary: '#475569', primaryDark: '#334155', primaryDarker: '#1e293b' },
|
||||
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
|
||||
];
|
||||
|
||||
export interface UserPreferences {
|
||||
colorScheme: string;
|
||||
geminiApiKey?: string;
|
||||
}
|
||||
|
||||
export type GameRole = 'HOST' | 'CLIENT';
|
||||
|
||||
export interface AnswerOption {
|
||||
|
|
@ -34,6 +62,7 @@ export interface GameConfig {
|
|||
shuffleQuestions: boolean;
|
||||
shuffleAnswers: boolean;
|
||||
hostParticipates: boolean;
|
||||
randomNamesEnabled: boolean;
|
||||
streakBonusEnabled: boolean;
|
||||
streakThreshold: number;
|
||||
streakMultiplier: number;
|
||||
|
|
@ -49,6 +78,7 @@ export const DEFAULT_GAME_CONFIG: GameConfig = {
|
|||
shuffleQuestions: false,
|
||||
shuffleAnswers: false,
|
||||
hostParticipates: true,
|
||||
randomNamesEnabled: false,
|
||||
streakBonusEnabled: false,
|
||||
streakThreshold: 3,
|
||||
streakMultiplier: 1.1,
|
||||
|
|
@ -142,6 +172,7 @@ export type NetworkMessage =
|
|||
options?: AnswerOption[];
|
||||
correctShape?: string;
|
||||
timeLeft?: number;
|
||||
assignedName?: string;
|
||||
} }
|
||||
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
|
||||
| { type: 'GAME_START'; payload: {} }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue