diff --git a/App.tsx b/App.tsx index 18cecb2..a0096ac 100644 --- a/App.tsx +++ b/App.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; import { useQuizLibrary } from './hooks/useQuizLibrary'; +import { useUserConfig } from './hooks/useUserConfig'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -12,7 +13,7 @@ import { RevealScreen } from './components/RevealScreen'; import { SaveQuizPrompt } from './components/SaveQuizPrompt'; import { QuizEditor } from './components/QuizEditor'; import { SaveOptionsModal } from './components/SaveOptionsModal'; -import type { Quiz } from './types'; +import type { Quiz, GameConfig } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -42,6 +43,7 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); + const { defaultConfig } = useUserConfig(); const [showSaveOptions, setShowSaveOptions] = useState(false); const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null); const { @@ -74,7 +76,8 @@ function App() { sourceQuizId, updateQuizFromEditor, startGameFromEditor, - backFromEditor + backFromEditor, + gameConfig } = useGame(); const handleSaveQuiz = async () => { @@ -152,7 +155,8 @@ function App() { onSave={handleEditorSave} onStartGame={startGameFromEditor} onBack={backFromEditor} - sourceQuizId={sourceQuizId} + showSaveButton={auth.isAuthenticated} + defaultConfig={defaultConfig} /> ) : null} @@ -195,6 +199,7 @@ function App() { onAnswer={handleAnswer} hasAnswered={hasAnswered} lastPointsEarned={lastPointsEarned} + hostPlays={gameConfig.hostParticipates} /> ) ) : null} diff --git a/components/DefaultConfigModal.tsx b/components/DefaultConfigModal.tsx new file mode 100644 index 0000000..c2831c1 --- /dev/null +++ b/components/DefaultConfigModal.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { X, Settings, Loader2 } from 'lucide-react'; +import { GameConfigPanel } from './GameConfigPanel'; +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import type { GameConfig } from '../types'; + +interface DefaultConfigModalProps { + isOpen: boolean; + onClose: () => void; + config: GameConfig; + onChange: (config: GameConfig) => void; + onSave: () => void; + saving: boolean; +} + +export const DefaultConfigModal: React.FC = ({ + isOpen, + onClose, + config, + onChange, + onSave, + saving, +}) => { + useBodyScrollLock(isOpen); + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > +
+
+
+ +
+
+

Default Game Settings

+

Applied to all new quizzes

+
+
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +}; diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx new file mode 100644 index 0000000..df29120 --- /dev/null +++ b/components/GameConfigPanel.tsx @@ -0,0 +1,325 @@ +import React, { useState } from 'react'; +import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react'; +import type { GameConfig } from '../types'; + +interface GameConfigPanelProps { + config: GameConfig; + onChange: (config: GameConfig) => void; + questionCount: number; + compact?: boolean; +} + +interface TooltipProps { + content: string; +} + +const Tooltip: React.FC = ({ content }) => { + const [show, setShow] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + const buttonRef = React.useRef(null); + + const updatePosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setCoords({ + top: rect.top + rect.height / 2, + left: rect.right + 8, + }); + } + }; + + const handleMouseEnter = () => { + updatePosition(); + setShow(true); + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + updatePosition(); + setShow(!show); + }; + + return ( +
+ + {show && ( +
+ {content} +
+
+ )} +
+ ); +}; + +interface ToggleRowProps { + icon: React.ReactNode; + iconActive: boolean; + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + tooltip?: string; + children?: React.ReactNode; +} + +const ToggleRow: React.FC = ({ + icon, + iconActive, + label: labelText, + description, + checked, + onChange, + tooltip, + children, +}) => { + const inputId = React.useId(); + + return ( +
+
+ +
+ {tooltip && } +
+ ); +}; + +interface NumberInputProps { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + suffix?: string; +} + +const NumberInput: React.FC = ({ label, value, onChange, min, max, step = 1, suffix }) => ( +
+ {label} +
+ onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + className="w-20 px-3 py-1.5 border-2 border-gray-200 rounded-lg text-center font-bold text-gray-900 bg-white focus:border-theme-primary focus:ring-2 focus:ring-theme-primary/20 outline-none" + /> + {suffix && {suffix}} +
+
+); + +export const GameConfigPanel: React.FC = ({ + config, + onChange, + questionCount, + compact = false, +}) => { + const [expanded, setExpanded] = useState(!compact); + + const update = (partial: Partial) => { + onChange({ ...config, ...partial }); + }; + + const suggestedComebackBonus = Math.round(50 + (questionCount * 5)); + const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5)); + + if (compact && !expanded) { + return ( + + ); + } + + return ( +
+ {compact && ( + + )} + + } + iconActive={config.shuffleQuestions} + label="Shuffle Questions" + description="Randomize question order when starting" + checked={config.shuffleQuestions} + onChange={(v) => update({ shuffleQuestions: v })} + /> + + } + iconActive={config.shuffleAnswers} + label="Shuffle Answers" + description="Randomize answer positions for each question" + checked={config.shuffleAnswers} + onChange={(v) => update({ shuffleAnswers: v })} + /> + + } + iconActive={config.hostParticipates} + label="Host Participates" + description="Join as a player and answer questions" + checked={config.hostParticipates} + onChange={(v) => update({ hostParticipates: v })} + /> + + } + iconActive={config.streakBonusEnabled} + label="Streak Bonus" + description="Multiply points for consecutive correct answers" + checked={config.streakBonusEnabled} + onChange={(v) => update({ streakBonusEnabled: v })} + tooltip={`After ${config.streakThreshold} correct answers in a row, points are multiplied by ${config.streakMultiplier}x. The multiplier increases by ${((config.streakMultiplier - 1) * 100).toFixed(0)}% for each additional correct answer.`} + > +
+ update({ streakThreshold: v })} + min={2} + max={10} + suffix="correct" + /> + update({ streakMultiplier: v })} + min={1.05} + max={2} + step={0.05} + suffix="x" + /> +
+
+ + } + iconActive={config.comebackBonusEnabled} + label="Comeback Bonus" + description="Extra points for players not in top 3" + checked={config.comebackBonusEnabled} + onChange={(v) => update({ comebackBonusEnabled: v })} + > +
+ update({ comebackBonusPoints: v })} + min={10} + max={500} + suffix="pts" + /> +

+ Suggested for {questionCount} questions: {suggestedComebackBonus} pts +

+
+
+ + } + iconActive={config.penaltyForWrongAnswer} + label="Wrong Answer Penalty" + description="Deduct points for incorrect answers" + checked={config.penaltyForWrongAnswer} + onChange={(v) => update({ penaltyForWrongAnswer: v })} + > +
+ update({ penaltyPercent: v })} + min={5} + max={100} + suffix="%" + /> +

+ Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts) +

+
+
+ + } + iconActive={config.firstCorrectBonusEnabled} + label="First Correct Bonus" + description="Extra points for first player to answer correctly" + checked={config.firstCorrectBonusEnabled} + onChange={(v) => update({ firstCorrectBonusEnabled: v })} + > +
+ update({ firstCorrectBonusPoints: v })} + min={10} + max={500} + suffix="pts" + /> +

+ Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts +

+
+
+
+ ); +}; diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index c6866fc..060de98 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -13,6 +13,7 @@ interface GameScreenProps { onAnswer: (isCorrect: boolean) => void; hasAnswered: boolean; lastPointsEarned: number | null; + hostPlays?: boolean; } export const GameScreen: React.FC = ({ @@ -24,8 +25,10 @@ export const GameScreen: React.FC = ({ role, onAnswer, hasAnswered, + hostPlays = true, }) => { const isClient = role === 'CLIENT'; + const isSpectator = role === 'HOST' && !hostPlays; const displayOptions = question?.options || []; const timeLeftSeconds = Math.ceil(timeLeft / 1000); @@ -51,7 +54,7 @@ export const GameScreen: React.FC = ({
- {isClient ? 'Controller' : 'Host'} + {isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
@@ -80,12 +83,14 @@ export const GameScreen: React.FC = ({ let opacityClass = "opacity-100"; let scaleClass = "scale-100"; + let cursorClass = ""; - // If answering phase and user answered, dim everything - if (hasAnswered) { + if (isSpectator) { + cursorClass = "cursor-default"; + } else if (hasAnswered) { opacityClass = "opacity-50 cursor-not-allowed grayscale"; scaleClass = "scale-95"; - } + } return ( = ({ initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }} - disabled={hasAnswered} - onClick={() => onAnswer(option as any)} + disabled={hasAnswered || isSpectator} + onClick={() => !isSpectator && onAnswer(option as any)} className={` - ${colorClass} ${opacityClass} ${scaleClass} + ${colorClass} ${opacityClass} ${scaleClass} ${cursorClass} rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)] flex flex-col md:flex-row items-center justify-center md:justify-start p-4 md:p-8 - active:shadow-none active:translate-y-[8px] active:scale-95 + ${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''} transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10 `} > diff --git a/components/Landing.tsx b/components/Landing.tsx index b82c94d..80628cf 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react'; import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; import { QuizLibrary } from './QuizLibrary'; +import { DefaultConfigModal } from './DefaultConfigModal'; import { useQuizLibrary } from '../hooks/useQuizLibrary'; -import type { Quiz } from '../types'; +import { useUserConfig } from '../hooks/useUserConfig'; +import type { Quiz, GameConfig } from '../types'; type GenerateMode = 'topic' | 'document'; @@ -31,10 +33,14 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [questionCount, setQuestionCount] = useState(10); const [isDragging, setIsDragging] = useState(false); const [useOcr, setUseOcr] = useState(false); + const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); + const [editingDefaultConfig, setEditingDefaultConfig] = useState(null); 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 { quizzes, @@ -132,7 +138,19 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on return (
-
+
+ {auth.isAuthenticated && ( + + )}
= ({ onGenerate, onCreateManual, on onDeleteQuiz={deleteQuiz} onRetry={retryLibrary} /> + + { + setDefaultConfigOpen(false); + setEditingDefaultConfig(null); + }} + config={editingDefaultConfig || defaultConfig} + onChange={setEditingDefaultConfig} + onSave={async () => { + if (editingDefaultConfig) { + await saveDefaultConfig(editingDefaultConfig); + setDefaultConfigOpen(false); + setEditingDefaultConfig(null); + } + }} + saving={savingConfig} + />
); }; diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index 09b2d28..8cb3157 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -1,41 +1,52 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Quiz, Question } from '../types'; +import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types'; import { SortableQuestionCard } from './SortableQuestionCard'; import { QuestionEditModal } from './QuestionEditModal'; +import { GameConfigPanel } from './GameConfigPanel'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import { v4 as uuidv4 } from 'uuid'; interface QuizEditorProps { quiz: Quiz; onSave: (quiz: Quiz) => void; - onStartGame: (quiz: Quiz) => void; + onStartGame: (quiz: Quiz, config: GameConfig) => void; + onConfigChange?: (config: GameConfig) => void; onBack: () => void; showSaveButton?: boolean; isSaving?: boolean; + defaultConfig?: GameConfig; } export const QuizEditor: React.FC = ({ quiz: initialQuiz, onSave, onStartGame, + onConfigChange, onBack, showSaveButton = true, - isSaving + isSaving, + defaultConfig, }) => { const [quiz, setQuiz] = useState(initialQuiz); const [expandedId, setExpandedId] = useState(null); const [editingQuestion, setEditingQuestion] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [titleEditing, setTitleEditing] = useState(false); - const [shuffleQuestions, setShuffleQuestions] = useState(false); - const [shuffleAnswers, setShuffleAnswers] = useState(false); + const [config, setConfig] = useState( + initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG + ); useBodyScrollLock(!!showDeleteConfirm); + const handleConfigChange = useCallback((newConfig: GameConfig) => { + setConfig(newConfig); + onConfigChange?.(newConfig); + }, [onConfigChange]); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } @@ -117,11 +128,11 @@ export const QuizEditor: React.FC = ({ const handleStartGame = () => { let questions = [...quiz.questions]; - if (shuffleQuestions) { + if (config.shuffleQuestions) { questions = questions.sort(() => Math.random() - 0.5); } - if (shuffleAnswers) { + if (config.shuffleAnswers) { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; @@ -138,7 +149,7 @@ export const QuizEditor: React.FC = ({ }); } - onStartGame({ ...quiz, questions }); + onStartGame({ ...quiz, questions, config }, config); }; return ( @@ -242,51 +253,12 @@ export const QuizEditor: React.FC = ({
-
- - - -
+