403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users } 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={<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>
|
|
);
|
|
};
|