feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic fix(test): fix navigation bug upon test quit
This commit is contained in:
145
src/components/CircularLevelProgress.tsx
Normal file
145
src/components/CircularLevelProgress.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfettiBurst } from "./ConfettiBurst";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
previousXP: number;
|
||||
gainedXP: number;
|
||||
levelMinXP: number;
|
||||
levelMaxXP: number;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export const CircularLevelProgress = ({
|
||||
size = 300,
|
||||
strokeWidth = 16,
|
||||
previousXP,
|
||||
gainedXP,
|
||||
levelMinXP,
|
||||
levelMaxXP,
|
||||
level,
|
||||
}: Props) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const levelRange = levelMaxXP - levelMinXP;
|
||||
|
||||
const normalize = (xp: number) =>
|
||||
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
|
||||
|
||||
const [progress, setProgress] = useState(normalize(previousXP));
|
||||
const [currentLevel, setCurrentLevel] = useState(level);
|
||||
const [showLevelUp, setShowLevelUp] = useState(false);
|
||||
const [showThresholdText, setShowThresholdText] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrame: number;
|
||||
let start: number | null = null;
|
||||
|
||||
const availableXP = previousXP + gainedXP;
|
||||
const crossesLevel = availableXP >= levelMaxXP;
|
||||
|
||||
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
|
||||
|
||||
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
|
||||
|
||||
const duration = 1200;
|
||||
|
||||
const animatePhase1 = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
const t = Math.min((timestamp - start) / duration, 1);
|
||||
|
||||
setProgress(
|
||||
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
||||
);
|
||||
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
} else if (crossesLevel) {
|
||||
setShowLevelUp(true);
|
||||
setTimeout(startPhase2, 1200);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
}
|
||||
};
|
||||
|
||||
const startPhase2 = () => {
|
||||
start = null;
|
||||
setShowLevelUp(false);
|
||||
setCurrentLevel((l) => l + 1);
|
||||
setProgress(0);
|
||||
|
||||
const target = Math.min(leftoverXP / levelRange, 1);
|
||||
|
||||
const animatePhase2 = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
const t = Math.min((timestamp - start) / duration, 1);
|
||||
|
||||
setProgress(t * target);
|
||||
|
||||
if (t < 1) {
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
} else {
|
||||
setShowThresholdText(true);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase2);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animatePhase1);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, []);
|
||||
|
||||
const offset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
{showLevelUp && <ConfettiBurst />}
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(94.6% 0.033 307.174)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="oklch(62.7% 0.265 303.9)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
|
||||
{currentLevel}
|
||||
|
||||
{showThresholdText && (
|
||||
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
|
||||
Total XP: {previousXP + gainedXP}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showLevelUp && (
|
||||
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
|
||||
🎉 You leveled up!
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user