kaboot/components/GameConfigPanel.tsx
Joey Yakimowich-Payne d1f82440a1
Add disable timer setting and fix per-question time limits
Add a 'Question Timer' toggle to game settings that lets the host disable
the countdown timer. When disabled, questions show ∞ instead of a countdown,
the host gets an 'End Question' button to manually advance, and all correct
answers receive maximum points.

Also fix a bug where per-question time limits were ignored — the timer and
scoring always used the hardcoded 20-second default instead of each question's
individual timeLimit.
2026-02-23 13:44:12 -07:00

414 lines
13 KiB
TypeScript

import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users, Timer } from 'lucide-react';
import type { GameConfig } from '../types';
interface GameConfigPanelProps {
config: GameConfig;
onChange: (config: GameConfig) => void;
questionCount: number;
compact?: boolean;
maxPlayersLimit?: number;
}
interface TooltipProps {
content: string;
}
const Tooltip: React.FC<TooltipProps> = ({ content }) => {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const buttonRef = React.useRef<HTMLButtonElement>(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 (
<div className="relative inline-block">
<button
ref={buttonRef}
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShow(false)}
onClick={handleClick}
className="p-1 text-gray-400 hover:text-gray-600 transition"
>
<Info size={16} />
</button>
{show && (
<div
className="fixed z-[100] w-64 p-3 text-sm bg-gray-900 text-white rounded-lg shadow-lg"
style={{
top: coords.top,
left: coords.left,
transform: 'translateY(-50%)',
}}
>
{content}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-2 h-2 bg-gray-900 rotate-45" />
</div>
)}
</div>
);
};
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<ToggleRowProps> = ({
icon,
iconActive,
label: labelText,
description,
checked,
onChange,
tooltip,
children,
}) => {
const inputId = React.useId();
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<label htmlFor={inputId} className="flex items-center gap-3 cursor-pointer flex-1">
<div className={`p-2 rounded-lg transition ${iconActive ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</label>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
<label htmlFor={inputId} className="relative cursor-pointer">
<input
id={inputId}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors" />
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5" />
</label>
</div>
</div>
{checked && children && (
<div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50">
{children}
</div>
)}
</div>
);
};
interface ValueRowProps {
icon: React.ReactNode;
label: string;
description: string;
tooltip?: string;
children: React.ReactNode;
}
const ValueRow: React.FC<ValueRowProps> = ({
icon,
label: labelText,
description,
tooltip,
children,
}) => {
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-theme-primary text-white transition">
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
{children}
</div>
</div>
</div>
);
};
interface NumberInputProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
}
const NumberInput: React.FC<NumberInputProps> = ({ label, value, onChange, min, max, step = 1, suffix }) => (
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<input
type="number"
value={value}
onChange={(e) => 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 && <span className="text-sm text-gray-500">{suffix}</span>}
</div>
</div>
);
export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
config,
onChange,
questionCount,
compact = false,
maxPlayersLimit = 150,
}) => {
const [expanded, setExpanded] = useState(!compact);
// Clamp maxPlayers if it exceeds the limit
React.useEffect(() => {
if (config.maxPlayers && config.maxPlayers > maxPlayersLimit) {
onChange({ ...config, maxPlayers: maxPlayersLimit });
}
}, [maxPlayersLimit, config.maxPlayers, onChange]);
const update = (partial: Partial<GameConfig>) => {
onChange({ ...config, ...partial });
};
const suggestedComebackBonus = Math.round(50 + (questionCount * 5));
const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5));
if (compact && !expanded) {
return (
<button
onClick={() => setExpanded(true)}
className="w-full flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 hover:border-theme-primary transition"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronDown size={20} className="text-gray-400" />
</button>
);
}
return (
<div className={compact ? "max-h-64 md:max-h-96 overflow-y-auto space-y-2" : "space-y-2"}>
{compact && (
<button
onClick={() => setExpanded(false)}
className="w-full flex items-center justify-between p-3 bg-gray-100 rounded-xl hover:bg-gray-200 transition sticky top-0 z-10"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronUp size={20} className="text-gray-400" />
</button>
)}
<ValueRow
icon={<Users size={20} />}
label="Max Players"
description="Limit the number of players who can join"
tooltip="The maximum number of players allowed in the game (2-150). Subscription required for >10 players."
>
<div className="flex flex-col items-end">
<NumberInput
label=""
value={config.maxPlayers || 10}
onChange={(v) => update({ maxPlayers: Math.min(v, maxPlayersLimit) })}
min={2}
max={maxPlayersLimit}
suffix="players"
/>
{maxPlayersLimit < 150 && (
<span className="text-xs text-amber-600 font-bold">
Free plan max: {maxPlayersLimit} players
</span>
)}
</div>
</ValueRow>
<ToggleRow
icon={<Timer size={20} />}
iconActive={config.timerEnabled}
label="Question Timer"
description="Countdown timer for each question"
checked={config.timerEnabled}
onChange={(v) => update({ timerEnabled: v })}
tooltip="When disabled, questions have no time limit. The host must manually end each question. All correct answers receive maximum points."
/>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}
label="Shuffle Questions"
description="Randomize question order when starting"
checked={config.shuffleQuestions}
onChange={(v) => update({ shuffleQuestions: v })}
/>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleAnswers}
label="Shuffle Answers"
description="Randomize answer positions for each question"
checked={config.shuffleAnswers}
onChange={(v) => update({ shuffleAnswers: v })}
/>
<ToggleRow
icon={<Eye size={20} />}
iconActive={config.hostParticipates}
label="Host Participates"
description="Join as a player and answer questions"
checked={config.hostParticipates}
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}
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.`}
>
<div className="mt-3 space-y-1">
<NumberInput
label="Streak threshold"
value={config.streakThreshold}
onChange={(v) => update({ streakThreshold: v })}
min={2}
max={10}
suffix="correct"
/>
<NumberInput
label="Multiplier"
value={config.streakMultiplier}
onChange={(v) => update({ streakMultiplier: v })}
min={1.05}
max={2}
step={0.05}
suffix="x"
/>
</div>
</ToggleRow>
<ToggleRow
icon={<TrendingUp size={20} />}
iconActive={config.comebackBonusEnabled}
label="Comeback Bonus"
description="Extra points for players not in top 3"
checked={config.comebackBonusEnabled}
onChange={(v) => update({ comebackBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.comebackBonusPoints}
onChange={(v) => update({ comebackBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedComebackBonus} pts
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<MinusCircle size={20} />}
iconActive={config.penaltyForWrongAnswer}
label="Wrong Answer Penalty"
description="Deduct points for incorrect answers"
checked={config.penaltyForWrongAnswer}
onChange={(v) => update({ penaltyForWrongAnswer: v })}
>
<div className="mt-3">
<NumberInput
label="Penalty"
value={config.penaltyPercent}
onChange={(v) => update({ penaltyPercent: v })}
min={5}
max={100}
suffix="%"
/>
<p className="text-xs text-gray-500 mt-1">
Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts)
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<Award size={20} />}
iconActive={config.firstCorrectBonusEnabled}
label="First Correct Bonus"
description="Extra points for first player to answer correctly"
checked={config.firstCorrectBonusEnabled}
onChange={(v) => update({ firstCorrectBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.firstCorrectBonusPoints}
onChange={(v) => update({ firstCorrectBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts
</p>
</div>
</ToggleRow>
</div>
);
};