complete design overhaul
This commit is contained in:
parent
d033a08d87
commit
0a0bafa0ec
14 changed files with 505 additions and 160 deletions
|
|
@ -35,16 +35,18 @@ const SettingGroup = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SettingGroupProps) => (
|
}: SettingGroupProps) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-sm font-medium text-purple-400 mb-2">{label}</p>
|
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
onClick={() => onSelect(option)}
|
onClick={() => onSelect(option)}
|
||||||
className={`py-2 px-5 rounded-xl font-semibold text-sm border-b-4 transition-all duration-200 cursor-pointer ${
|
className={`py-2 px-5 rounded-xl font-semibold text-sm border transition-all duration-200 cursor-pointer ${
|
||||||
selected === option
|
selected === option
|
||||||
? "bg-purple-600 text-white border-purple-800"
|
? "bg-(--color-primary) text-white border-(--color-primary-dark) shadow-sm"
|
||||||
: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300"
|
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{LABELS[option] ?? option}
|
{LABELS[option] ?? option}
|
||||||
|
|
@ -91,12 +93,18 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-8 text-center">
|
||||||
<h1 className="text-3xl font-bold text-purple-900 mb-1">lila</h1>
|
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
||||||
<p className="text-sm text-gray-400">Set up your quiz</p>
|
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
||||||
|
<h1 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-1">
|
||||||
|
lila
|
||||||
|
</h1>
|
||||||
|
<p className="relative text-sm text-(--color-text-muted)">
|
||||||
|
Set up your quiz
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-6 w-full flex flex-col gap-5">
|
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-6 flex flex-col gap-5">
|
||||||
<SettingGroup
|
<SettingGroup
|
||||||
label="I speak"
|
label="I speak"
|
||||||
options={SUPPORTED_LANGUAGE_CODES}
|
options={SUPPORTED_LANGUAGE_CODES}
|
||||||
|
|
@ -131,9 +139,9 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
className="w-full py-4 rounded-2xl text-xl font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
className="w-full py-4 rounded-2xl text-xl font-black bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Start Quiz
|
Start
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,39 @@ type OptionButtonProps = {
|
||||||
|
|
||||||
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
||||||
const base =
|
const base =
|
||||||
"w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer";
|
"group relative w-full overflow-hidden py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border cursor-pointer text-left";
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2",
|
idle: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0",
|
||||||
selected:
|
selected:
|
||||||
"bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400",
|
"bg-(--color-surface) text-(--color-primary-dark) border-(--color-primary) ring-2 ring-(--color-primary)",
|
||||||
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
|
disabled:
|
||||||
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
|
"bg-(--color-surface) text-(--color-primary-light) border-(--color-primary-light) cursor-default",
|
||||||
wrong: "bg-pink-400 text-white border-pink-600",
|
correct:
|
||||||
|
"bg-emerald-400/90 text-white border-emerald-600 ring-2 ring-emerald-300 scale-[1.01]",
|
||||||
|
wrong: "bg-pink-500/90 text-white border-pink-700 ring-2 ring-pink-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const motion =
|
||||||
|
state === "correct" ? "lila-pop" : state === "wrong" ? "lila-shake" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${base} ${styles[state]}`}
|
className={`${base} ${styles[state]} ${motion}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={
|
disabled={
|
||||||
state === "disabled" || state === "correct" || state === "wrong"
|
state === "disabled" || state === "correct" || state === "wrong"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{text}
|
<span className="absolute inset-0 -z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="absolute -top-10 -right-12 h-24 w-24 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl" />
|
||||||
|
<span className="absolute -bottom-10 -left-12 h-24 w-24 rounded-full bg-(--color-accent) opacity-[0.10] blur-2xl" />
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate">{text}</span>
|
||||||
|
{state === "correct" && <span aria-hidden>✓</span>}
|
||||||
|
{state === "wrong" && <span aria-hidden>✕</span>}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,31 @@ export const QuestionCard = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-purple-400">
|
<div className="w-full flex items-center justify-between">
|
||||||
<span>
|
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
||||||
{questionNumber} / {totalQuestions}
|
Round {questionNumber}/{totalQuestions}
|
||||||
</span>
|
</div>
|
||||||
|
<div className="text-xs font-semibold text-(--color-text-muted)">
|
||||||
|
{currentResult ? "Checked" : selectedOptionId !== null ? "Ready" : "Pick one"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
<div className="relative w-full overflow-hidden rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-8 text-center">
|
||||||
<h2 className="text-3xl font-bold text-purple-900 mb-2">
|
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
||||||
|
|
||||||
|
<h2 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-2">
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</h2>
|
||||||
{question.gloss && (
|
{question.gloss && (
|
||||||
<p className="text-sm text-gray-400 italic">{question.gloss}</p>
|
<p className="relative text-sm text-(--color-text-muted) italic">
|
||||||
|
{question.gloss}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white/55 dark:bg-black/10 backdrop-blur shadow-sm p-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
{question.options.map((option) => (
|
{question.options.map((option) => (
|
||||||
<OptionButton
|
<OptionButton
|
||||||
key={option.optionId}
|
key={option.optionId}
|
||||||
|
|
@ -73,20 +82,21 @@ export const QuestionCard = ({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!currentResult && selectedOptionId !== null && (
|
{!currentResult && selectedOptionId !== null && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Submit
|
Lock it in
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentResult && (
|
{currentResult && (
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AnswerResult } from "@lila/shared";
|
import type { AnswerResult } from "@lila/shared";
|
||||||
|
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
||||||
|
|
||||||
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
||||||
|
|
||||||
|
|
@ -17,30 +18,38 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-10 w-full text-center">
|
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-10 text-center">
|
||||||
<p className="text-lg font-medium text-purple-400 mb-2">Your Score</p>
|
{percentage === 100 && <ConfettiBurst />}
|
||||||
<h2 className="text-6xl font-bold text-purple-900 mb-1">
|
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
||||||
|
|
||||||
|
<p className="relative inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-3">
|
||||||
|
Results
|
||||||
|
</p>
|
||||||
|
<h2 className="relative text-6xl font-black tracking-tight text-(--color-text) mb-1">
|
||||||
{score}/{total}
|
{score}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-2xl mb-6">{getMessage()}</p>
|
<p className="relative text-2xl mb-6">{getMessage()}</p>
|
||||||
|
|
||||||
<div className="w-full bg-purple-100 rounded-full h-4 mb-2">
|
<div className="relative w-full bg-(--color-surface) border border-(--color-primary-light) rounded-full h-4 mb-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-400">{percentage}% correct</p>
|
<p className="relative text-sm text-(--color-text-muted)">
|
||||||
|
{percentage}% correct
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<div
|
<div
|
||||||
key={result.questionId}
|
key={result.questionId}
|
||||||
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm ${
|
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm border ${
|
||||||
result.isCorrect
|
result.isCorrect
|
||||||
? "bg-emerald-50 text-emerald-700"
|
? "bg-emerald-50/60 text-emerald-700 border-emerald-200"
|
||||||
: "bg-pink-50 text-pink-700"
|
: "bg-pink-50/60 text-pink-700 border-pink-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-bold">{index + 1}.</span>
|
<span className="font-bold">{index + 1}.</span>
|
||||||
|
|
@ -51,9 +60,9 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onPlayAgain}
|
onClick={onPlayAgain}
|
||||||
className="py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Play Again
|
Play again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,29 +12,52 @@ const features = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "⚔️",
|
emoji: "⚔️",
|
||||||
title: "Multiplayer coming",
|
title: "Real-time multiplayer",
|
||||||
description: "Challenge friends and see who has the bigger vocabulary.",
|
description: "Create a room, share the code, and race to the best score.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const FeatureCards = () => {
|
const FeatureCards = () => {
|
||||||
return (
|
return (
|
||||||
<section className="py-16">
|
<section className="py-14">
|
||||||
<h2 className="text-center text-3xl font-black tracking-tight text-(--color-text) mb-12">
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
||||||
|
Tiny rounds · big dopamine
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-black tracking-tight text-(--color-text)">
|
||||||
Why lila
|
Why lila
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
||||||
|
Built to be fast to start, satisfying to finish, and fun to repeat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{features.map(({ emoji, title, description }) => (
|
{features.map(({ emoji, title, description }) => (
|
||||||
<div
|
<div
|
||||||
key={title}
|
key={title}
|
||||||
className="flex flex-col gap-3 p-6 rounded-2xl border border-(--color-primary-light)"
|
className="group relative overflow-hidden rounded-2xl border border-(--color-primary-light) bg-(--color-bg) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<span className="text-3xl">{emoji}</span>
|
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center text-2xl">
|
||||||
|
<span aria-hidden>{emoji}</span>
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">{title}</h3>
|
<h3 className="text-lg font-bold text-(--color-text)">{title}</h3>
|
||||||
<p className="text-sm text-(--color-text-muted) leading-relaxed">
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-(--color-text-muted) leading-relaxed">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
||||||
|
Instant feedback
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)" />
|
||||||
|
Type-safe API
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,58 +5,133 @@ const Hero = () => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex flex-col items-center text-center pt-20 pb-16">
|
<section className="relative pt-10 md:pt-16 pb-10 md:pb-14">
|
||||||
<div className="-rotate-1 mb-2">
|
<div className="absolute inset-0 -z-10">
|
||||||
<span className="text-sm font-semibold tracking-widest uppercase text-(--color-accent)">
|
<div className="absolute -top-24 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
||||||
Vocabulary trainer
|
<div className="absolute -top-10 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid items-center gap-10 md:grid-cols-2">
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<div className="-rotate-1 mb-3">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
||||||
|
Duolingo-style drills · real-time multiplayer
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-6xl font-black tracking-tight text-(--color-text) leading-tight">
|
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-(--color-text) leading-[1.05]">
|
||||||
Meet{" "}
|
Learn vocabulary fast,{" "}
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
||||||
lila
|
together
|
||||||
</span>
|
</span>
|
||||||
|
.
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-6 text-xl font-medium text-(--color-text-muted) max-w-sm">
|
<p className="mt-5 text-lg md:text-xl font-medium text-(--color-text-muted) max-w-xl mx-auto md:mx-0">
|
||||||
Learn words.{" "}
|
A word appears. You pick the translation. You score points.
|
||||||
<span className="text-(--color-accent) font-bold">Beat friends.</span>
|
Then you queue up a room and{" "}
|
||||||
|
<span className="text-(--color-accent) font-bold">beat friends</span>{" "}
|
||||||
|
in real time.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 flex-wrap justify-center">
|
<div className="mt-4 flex gap-2 flex-wrap justify-center md:justify-start">
|
||||||
{["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => (
|
{["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => (
|
||||||
<span key={flag} className="text-2xl">
|
<span key={flag} className="text-2xl" aria-hidden>
|
||||||
{flag}
|
{flag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
<span className="sr-only">
|
||||||
|
Supported languages: English, Italian, German, French, Spanish
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 flex gap-4">
|
<div className="mt-8 flex gap-3 flex-wrap justify-center md:justify-start">
|
||||||
{session ? (
|
{session ? (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/play"
|
to="/play"
|
||||||
className="px-8 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
||||||
>
|
>
|
||||||
Start playing →
|
Play solo
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/multiplayer"
|
||||||
|
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
||||||
|
>
|
||||||
|
Play with friends
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="px-8 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
||||||
>
|
>
|
||||||
Get started →
|
Get started
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="px-8 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
||||||
>
|
>
|
||||||
Login
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/20 backdrop-blur p-3 shadow-sm">
|
||||||
|
<div className="rounded-2xl bg-(--color-bg) border border-(--color-primary-light) overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-(--color-surface) border-b border-(--color-primary-light)">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-(--color-accent)" />
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-(--color-primary-light)" />
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-(--color-text-muted) opacity-40" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-(--color-text-muted)">
|
||||||
|
Live preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 md:p-6">
|
||||||
|
<p className="text-xs font-semibold tracking-widest uppercase text-(--color-text-muted)">
|
||||||
|
Translate
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-5">
|
||||||
|
<div className="text-3xl font-black text-(--color-text)">
|
||||||
|
finestra
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-(--color-text-muted)">
|
||||||
|
(noun) · A2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
{["window", "forest", "river", "kitchen"].map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
className="rounded-xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm font-semibold text-(--color-text) hover:bg-(--color-surface)"
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-(--color-text-muted)">
|
||||||
|
Round 2/10
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-semibold text-(--color-text-muted)">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-(--color-accent)" />
|
||||||
|
Multiplayer room
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,26 +20,55 @@ const steps = [
|
||||||
|
|
||||||
const HowItWorks = () => {
|
const HowItWorks = () => {
|
||||||
return (
|
return (
|
||||||
<section className="py-16">
|
<section className="py-14">
|
||||||
<h2 className="text-center text-3xl font-black tracking-tight text-(--color-text) mb-12">
|
<div className="relative -mx-6 px-6 py-12 rounded-3xl bg-(--color-surface) border border-(--color-primary-light) overflow-hidden">
|
||||||
|
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.12] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.14] blur-3xl" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-bg) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-accent)">
|
||||||
|
Quick · satisfying · replayable
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-(--color-text)">
|
||||||
How it works
|
How it works
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
Short rounds, instant feedback, and just enough pressure to make the
|
||||||
{steps.map(({ number, title, description }) => (
|
words stick.
|
||||||
<div
|
|
||||||
key={number}
|
|
||||||
className="flex flex-col gap-3 p-6 rounded-2xl bg-(--color-surface) border border-(--color-primary-light)"
|
|
||||||
>
|
|
||||||
<span className="text-4xl font-black text-(--color-primary-light)">
|
|
||||||
{number}
|
|
||||||
</span>
|
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">{title}</h3>
|
|
||||||
<p className="text-sm text-(--color-text-muted) leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ol className="relative mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{steps.map(({ number, title, description }) => (
|
||||||
|
<li
|
||||||
|
key={number}
|
||||||
|
className="group relative overflow-hidden rounded-2xl bg-(--color-bg) border border-(--color-primary-light) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
||||||
|
<div className="absolute -bottom-24 -left-24 h-48 w-48 rounded-full bg-(--color-accent) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:-translate-x-2 group-hover:translate-y-2" />
|
||||||
|
<div className="relative flex items-start gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center">
|
||||||
|
<span className="text-sm font-black tracking-widest text-(--color-primary)">
|
||||||
|
{number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-(--color-text)">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-(--color-text-muted) leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
||||||
|
Under 30 seconds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import type { LobbyPlayer } from "@lila/shared";
|
import type { LobbyPlayer } from "@lila/shared";
|
||||||
|
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
||||||
|
|
||||||
type MultiplayerScoreScreenProps = {
|
type MultiplayerScoreScreenProps = {
|
||||||
players: LobbyPlayer[];
|
players: LobbyPlayer[];
|
||||||
|
|
@ -26,19 +27,27 @@ export const MultiplayerScoreScreen = ({
|
||||||
.join(" and ");
|
.join(" and ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen relative flex items-center justify-center p-6">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
<div className="absolute inset-0 -z-10 bg-linear-to-b from-purple-100 to-pink-50" />
|
||||||
|
<div className="absolute -top-24 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl -z-10" />
|
||||||
|
<div className="absolute -top-8 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl -z-10" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
||||||
|
{isWinner && !isTie && <ConfettiBurst />}
|
||||||
{/* Result header */}
|
{/* Result header */}
|
||||||
<div className="text-center flex flex-col gap-1">
|
<div className="text-center flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold text-purple-800">
|
<div className="inline-flex mx-auto items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
||||||
|
Multiplayer
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-black tracking-tight text-(--color-text)">
|
||||||
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200" />
|
<div className="border-t border-(--color-primary-light) opacity-60" />
|
||||||
|
|
||||||
{/* Score list */}
|
{/* Score list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -48,35 +57,35 @@ export const MultiplayerScoreScreen = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
|
className={`flex items-center justify-between rounded-2xl px-4 py-3 border ${
|
||||||
isCurrentUser
|
isCurrentUser
|
||||||
? "bg-purple-50 border border-purple-200"
|
? "bg-(--color-surface) border-(--color-primary-light)"
|
||||||
: "bg-gray-50"
|
: "bg-white/30 dark:bg-black/10 border-(--color-primary-light)"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-gray-400 w-4">
|
<span className="text-sm font-bold text-(--color-text-muted) w-4">
|
||||||
{index + 1}.
|
{index + 1}.
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-semibold ${
|
||||||
isCurrentUser ? "text-purple-800" : "text-gray-700"
|
isCurrentUser ? "text-(--color-text)" : "text-(--color-text)"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="text-xs text-purple-400 ml-1">
|
<span className="text-xs text-(--color-primary) ml-1">
|
||||||
(you)
|
(you)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isPlayerWinner && (
|
{isPlayerWinner && (
|
||||||
<span className="text-xs text-yellow-500 font-medium">
|
<span className="text-xs font-medium" aria-label="Winner">
|
||||||
👑
|
👑
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-gray-700">
|
<span className="text-sm font-black text-(--color-text)">
|
||||||
{player.score} pts
|
{player.score} pts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,12 +93,12 @@ export const MultiplayerScoreScreen = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200" />
|
<div className="border-t border-(--color-primary-light) opacity-60" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
|
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/multiplayer/lobby/$code",
|
to: "/multiplayer/lobby/$code",
|
||||||
|
|
@ -100,7 +109,7 @@ export const MultiplayerScoreScreen = ({
|
||||||
Play Again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
|
className="rounded-2xl bg-white/30 dark:bg-black/10 border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-bold hover:bg-(--color-surface) transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({ to: "/multiplayer" });
|
void navigate({ to: "/multiplayer" });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
103
apps/web/src/components/ui/ConfettiBurst.tsx
Normal file
103
apps/web/src/components/ui/ConfettiBurst.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useEffect, useMemo, useState, useId } from "react";
|
||||||
|
|
||||||
|
type ConfettiBurstProps = {
|
||||||
|
className?: string;
|
||||||
|
colors?: string[];
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Piece = {
|
||||||
|
id: number;
|
||||||
|
style: React.CSSProperties & ConfettiVars;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfettiVars = {
|
||||||
|
["--x0"]: string;
|
||||||
|
["--y0"]: string;
|
||||||
|
["--x1"]: string;
|
||||||
|
["--y1"]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashStringToUint32 = (value: string) => {
|
||||||
|
// FNV-1a 32-bit
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash ^= value.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mulberry32 = (seed: number) => {
|
||||||
|
return () => {
|
||||||
|
let t = (seed += 0x6d2b79f5);
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfettiBurst = ({
|
||||||
|
className,
|
||||||
|
colors = [
|
||||||
|
"var(--color-primary)",
|
||||||
|
"var(--color-accent)",
|
||||||
|
"var(--color-primary-light)",
|
||||||
|
"var(--color-accent-light)",
|
||||||
|
],
|
||||||
|
count = 18,
|
||||||
|
}: ConfettiBurstProps) => {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
const instanceId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setVisible(false), 1100);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pieces = useMemo<Piece[]>(() => {
|
||||||
|
const seed = hashStringToUint32(`${instanceId}:${count}:${colors.join(",")}`);
|
||||||
|
const rand = mulberry32(seed);
|
||||||
|
const rnd = (min: number, max: number) => min + rand() * (max - min);
|
||||||
|
|
||||||
|
return Array.from({ length: count }).map((_, i) => {
|
||||||
|
const x0 = rnd(-6, 6);
|
||||||
|
const y0 = rnd(-6, 6);
|
||||||
|
const x1 = rnd(-160, 160);
|
||||||
|
const y1 = rnd(60, 220);
|
||||||
|
const delay = rnd(0, 120);
|
||||||
|
const rotate = rnd(0, 360);
|
||||||
|
const color = colors[i % colors.length];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: i,
|
||||||
|
style: {
|
||||||
|
left: "50%",
|
||||||
|
top: "0%",
|
||||||
|
backgroundColor: color,
|
||||||
|
transform: `translate(${x0}px, ${y0}px) rotate(${rotate}deg)`,
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
// consumed by keyframes
|
||||||
|
["--x0"]: `${x0}px`,
|
||||||
|
["--y0"]: `${y0}px`,
|
||||||
|
["--x1"]: `${x1}px`,
|
||||||
|
["--y1"]: `${y1}px`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [colors, count, instanceId]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-0 overflow-visible ${className ?? ""}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{pieces.map((p) => (
|
||||||
|
<span key={p.id} className="lila-confetti-piece" style={p.style} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -26,3 +26,65 @@
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes lila-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lila-shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lila-confetti {
|
||||||
|
0% {
|
||||||
|
transform: translate(var(--x0), var(--y0)) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(var(--x1), var(--y1)) rotate(540deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lila-pop {
|
||||||
|
animation: lila-pop 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lila-shake {
|
||||||
|
animation: lila-shake 260ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lila-confetti-piece {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: lila-confetti 900ms ease-out forwards;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const LoginPage = () => {
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
||||||
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
className="w-64 rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void signIn
|
void signIn
|
||||||
.social({ provider: "github", callbackURL: window.location.origin })
|
.social({ provider: "github", callbackURL: window.location.origin })
|
||||||
|
|
@ -28,7 +28,7 @@ const LoginPage = () => {
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
className="w-64 rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void signIn
|
void signIn
|
||||||
.social({ provider: "google", callbackURL: window.location.origin })
|
.social({ provider: "google", callbackURL: window.location.origin })
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ function GamePage() {
|
||||||
// Phase: playing
|
// Phase: playing
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted) text-center">
|
||||||
Question {currentQuestion.questionNumber} of{" "}
|
Question {currentQuestion.questionNumber} of{" "}
|
||||||
{currentQuestion.totalQuestions}
|
{currentQuestion.totalQuestions}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,7 +150,7 @@ function GamePage() {
|
||||||
{/* Round results */}
|
{/* Round results */}
|
||||||
{answerResult && (
|
{answerResult && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">
|
<h3 className="text-sm font-black text-(--color-text)">
|
||||||
Round results
|
Round results
|
||||||
</h3>
|
</h3>
|
||||||
{answerResult.players.map((player) => {
|
{answerResult.players.map((player) => {
|
||||||
|
|
@ -160,9 +160,9 @@ function GamePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center justify-between text-sm"
|
className="flex items-center justify-between text-sm text-(--color-text)"
|
||||||
>
|
>
|
||||||
<span className="text-gray-700">{player.user.name}</span>
|
<span className="font-semibold">{player.user.name}</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
result?.isCorrect
|
result?.isCorrect
|
||||||
|
|
@ -176,7 +176,9 @@ function GamePage() {
|
||||||
? "✓ Correct"
|
? "✓ Correct"
|
||||||
: "✗ Wrong"}
|
: "✗ Wrong"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500">{player.score} pts</span>
|
<span className="text-(--color-text-muted)">
|
||||||
|
{player.score} pts
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-bold text-center text-purple-800">
|
<h1 className="text-2xl font-black tracking-tight text-center text-(--color-text)">
|
||||||
Multiplayer
|
Multiplayer
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|
@ -85,14 +85,14 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
{/* Create lobby */}
|
{/* Create lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-700">
|
<h2 className="text-lg font-bold text-(--color-text)">
|
||||||
Create a lobby
|
Create a lobby
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Start a new game and invite friends with a code.
|
Start a new game and invite friends with a code.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCreate().catch((err) => {
|
void handleCreate().catch((err) => {
|
||||||
console.error("Create lobby error:", err);
|
console.error("Create lobby error:", err);
|
||||||
|
|
@ -104,16 +104,16 @@ function MultiplayerPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200" />
|
<div className="border-t border-(--color-primary-light) opacity-60" />
|
||||||
|
|
||||||
{/* Join lobby */}
|
{/* Join lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
|
<h2 className="text-lg font-bold text-(--color-text)">Join a lobby</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-(--color-text-muted)">
|
||||||
Enter the code shared by your host.
|
Enter the code shared by your host.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
|
className="rounded-2xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm uppercase tracking-widest text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
||||||
placeholder="Enter code (e.g. WOLF42)"
|
placeholder="Enter code (e.g. WOLF42)"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
onChange={(e) => setJoinCode(e.target.value)}
|
onChange={(e) => setJoinCode(e.target.value)}
|
||||||
|
|
@ -128,7 +128,7 @@ function MultiplayerPage() {
|
||||||
disabled={isCreating || isJoining}
|
disabled={isCreating || isJoining}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
|
className="rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-black hover:bg-white/30 dark:hover:bg-black/10 shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleJoin().catch((err) => {
|
void handleJoin().catch((err) => {
|
||||||
console.error("Join lobby error:", err);
|
console.error("Join lobby error:", err);
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,14 @@ function LobbyPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
||||||
{/* Lobby code */}
|
{/* Lobby code */}
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<p className="text-sm text-gray-500">Lobby code</p>
|
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted)">
|
||||||
|
Lobby code
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
|
className="text-4xl font-black tracking-widest text-(--color-text) hover:text-(--color-primary) cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(code);
|
void navigator.clipboard.writeText(code);
|
||||||
}}
|
}}
|
||||||
|
|
@ -101,21 +103,21 @@ function LobbyPage() {
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-gray-400">Click to copy</p>
|
<p className="text-xs text-(--color-text-muted)">Click to copy</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200" />
|
<div className="border-t border-(--color-primary-light) opacity-60" />
|
||||||
|
|
||||||
{/* Player list */}
|
{/* Player list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-700">
|
<h2 className="text-lg font-black text-(--color-text)">
|
||||||
Players ({lobby.players.length})
|
Players ({lobby.players.length})
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{lobby.players.map((player) => (
|
{lobby.players.map((player) => (
|
||||||
<li
|
<li
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center gap-2 text-sm text-gray-700"
|
className="flex items-center gap-2 text-sm text-(--color-text)"
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
|
|
@ -135,7 +137,7 @@ function LobbyPage() {
|
||||||
{/* Start button — host only */}
|
{/* Start button — host only */}
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<button
|
<button
|
||||||
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
>
|
>
|
||||||
|
|
@ -149,7 +151,7 @@ function LobbyPage() {
|
||||||
|
|
||||||
{/* Non-host waiting message */}
|
{/* Non-host waiting message */}
|
||||||
{!isHost && (
|
{!isHost && (
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-(--color-text-muted) text-center">
|
||||||
Waiting for host to start the game...
|
Waiting for host to start the game...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue