fix(lint): resolve every eslint warning without suppressions
react-hooks/set-state-in-effect and react-hooks/purity had been demoted to warnings "so CI passes", and CI ran without --max-warnings 0, so all 22 violations were invisible there. Both rules are back at error. - Confetti is rolled once per mount instead of during render, where it re-rolled on every re-render and made pieces visibly jump. - Loading flags are derived from a "last settled request" key rather than set synchronously inside an effect. That also adds the cancellation these fetches were missing, so a resolved request can no longer write into a unmounted or superseded view. - Effect dependency arrays corrected; the two eslint-disable-line comments in Profile.tsx are gone, and the split of its two effects removes the guard that silently never re-ran. - GameCompletionModal derives visibility from status instead of mirroring it into state, with a ref so the sound and localStorage write fire once. - ValidationFeedback adjusts state during render, the documented pattern, instead of paying an extra committed render per link. - The three react-refresh suppressions in ui/ are resolved properly by extracting the variants into their own modules, so the component files export only components. badgeVariants had no consumers and is no longer exported. - Drops a stale eslint-disable directive in sw.js. The lint script now fails on warnings so this cannot quietly regress.
This commit is contained in:
1 parent
70d53ed37d
commit
470a67607b
21 files changed
+337
-210
No files matched your search
+7
-6
@@ -16,15 +16,16 @@ export default defineConfig([
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
// Matches the ES2022 target in tsconfig.app.json.
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// The react-hooks v6 "set-state-in-effect" and "purity" rules flag
|
||||
// standard data-fetching patterns. Surface as warnings so CI passes
|
||||
// and they remain visible in the editor.
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
// Left at their recommended 'error' severity. These were previously
|
||||
// downgraded to 'warn' so CI would pass, which meant every violation was
|
||||
// invisible in CI, including the stale-closure and cascading-render bugs
|
||||
// that downgrade was hiding. The underlying violations are fixed, so the
|
||||
// rules stay enforcing.
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"release": "commit-and-tag-version",
|
||||
"release:minor": "commit-and-tag-version --release-as minor",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
@@ -8,8 +8,31 @@ const CONFETTI_COLORS = [
|
||||
|
||||
const CONFETTI_COUNT = 20;
|
||||
|
||||
interface ConfettiPiece {
|
||||
color: string;
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
round: boolean;
|
||||
}
|
||||
|
||||
function makeConfetti(): ConfettiPiece[] {
|
||||
return Array.from({ length: CONFETTI_COUNT }, () => ({
|
||||
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 1,
|
||||
duration: 2 + Math.random() * 1.5,
|
||||
size: 6 + Math.random() * 6,
|
||||
round: Math.random() > 0.5,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function CelebrationOverlay() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
// Rolled once per mount. Randomising in the render body re-rolled every piece
|
||||
// on each re-render, so confetti visibly jumped mid-flight.
|
||||
const [pieces] = useState(makeConfetti);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000);
|
||||
@@ -20,31 +43,22 @@ export default function CelebrationOverlay() {
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
|
||||
{Array.from({ length: CONFETTI_COUNT }).map((_, i) => {
|
||||
const color =
|
||||
CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
||||
const left = Math.random() * 100;
|
||||
const delay = Math.random() * 1;
|
||||
const duration = 2 + Math.random() * 1.5;
|
||||
const size = 6 + Math.random() * 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
top: '-10px',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: color,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||
animationDelay: `${delay}s`,
|
||||
animationDuration: `${duration}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pieces.map((piece, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${piece.left}%`,
|
||||
top: '-10px',
|
||||
width: `${piece.size}px`,
|
||||
height: `${piece.size}px`,
|
||||
backgroundColor: piece.color,
|
||||
borderRadius: piece.round ? '50%' : '2px',
|
||||
animationDelay: `${piece.delay}s`,
|
||||
animationDuration: `${piece.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,25 +36,39 @@ export default function GameCompletionModal() {
|
||||
const validationError = useGameStore((s) => s.validationError);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const navigate = useNavigate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
const celebrated = useRef(false);
|
||||
|
||||
// Derived from status rather than mirrored into state by an effect, with
|
||||
// dismissal reset as soon as the game leaves the completed state.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
if (status !== 'completed') setDismissed(false);
|
||||
}
|
||||
const showModal = status === 'completed' && !dismissed;
|
||||
|
||||
const gameMode: GameMode = isDailyChallenge && dailyChallengeDate
|
||||
? { type: 'daily', date: dailyChallengeDate }
|
||||
: { type: 'freeplay' };
|
||||
|
||||
// Side effects only — fired once per completion, guarded by a ref so a
|
||||
// re-render or dependency change cannot replay the sound or the write.
|
||||
useEffect(() => {
|
||||
if (status === 'completed') {
|
||||
setShowModal(true);
|
||||
playSound('completion');
|
||||
|
||||
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
|
||||
if (isDailyChallenge && dailyChallengeDate) {
|
||||
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
|
||||
}
|
||||
} else {
|
||||
setShowModal(false);
|
||||
if (status !== 'completed') {
|
||||
celebrated.current = false;
|
||||
return;
|
||||
}
|
||||
}, [status]);
|
||||
if (celebrated.current) return;
|
||||
celebrated.current = true;
|
||||
|
||||
playSound('completion');
|
||||
|
||||
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
|
||||
if (isDailyChallenge && dailyChallengeDate) {
|
||||
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
|
||||
}
|
||||
}, [status, isDailyChallenge, dailyChallengeDate, dailyChallengeDifficulty]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
resetGame();
|
||||
@@ -62,13 +76,13 @@ export default function GameCompletionModal() {
|
||||
};
|
||||
|
||||
const handleViewChain = () => {
|
||||
setShowModal(false);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'completed' && <CelebrationOverlay />}
|
||||
<Dialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}>
|
||||
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -7,15 +7,21 @@ export default function ValidationFeedback() {
|
||||
const validationError = useGameStore((s) => s.validationError);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
const [flashedLength, setFlashedLength] = useState(0);
|
||||
|
||||
// Flash a green check when the chain grows (successful validation). Adjusting
|
||||
// state during render is React's documented pattern for reacting to a changed
|
||||
// value; doing it in an effect costs an extra committed render each link.
|
||||
if (chain.length > 1 && chain.length !== flashedLength) {
|
||||
setFlashedLength(chain.length);
|
||||
setShowCheck(true);
|
||||
}
|
||||
|
||||
// Flash a green check when chain grows (successful validation)
|
||||
useEffect(() => {
|
||||
if (chain.length > 1) {
|
||||
setShowCheck(true);
|
||||
const timer = setTimeout(() => setShowCheck(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [chain.length]);
|
||||
if (!showCheck) return;
|
||||
const timer = setTimeout(() => setShowCheck(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [showCheck]);
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
|
||||
@@ -49,5 +49,4 @@ function Badge({
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge }
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,45 +1,10 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { buttonVariants } from "./button-variants"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
@@ -55,5 +20,4 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
@@ -1,24 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
const DIFFICULTY_STYLES: Record<Difficulty, string> = {
|
||||
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
|
||||
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getDifficultyButtonClass(difficulty: Difficulty, isActive: boolean): string {
|
||||
if (!isActive) return '';
|
||||
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
|
||||
}
|
||||
import { DIFFICULTY_STYLES, type Difficulty } from './difficulty-styles';
|
||||
|
||||
export function DifficultyBadge({
|
||||
difficulty,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export const DIFFICULTY_STYLES: Record<Difficulty, string> = {
|
||||
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
|
||||
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
export function getDifficultyButtonClass(
|
||||
difficulty: Difficulty,
|
||||
isActive: boolean,
|
||||
): string {
|
||||
if (!isActive) return '';
|
||||
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
|
||||
}
|
||||
+14
-7
@@ -15,18 +15,25 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function elapsedFrom(startTime: number | null): number {
|
||||
if (!startTime) return 0;
|
||||
return Math.max(0, Math.floor((Date.now() - startTime) / 1000));
|
||||
}
|
||||
|
||||
export function useTimer(startTime: number | null, isRunning: boolean) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
// Seeded lazily so the first render is already correct; the interval then
|
||||
// recomputes from Date.now() each tick, so it cannot drift.
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(() =>
|
||||
elapsedFrom(startTime),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startTime || !isRunning) return;
|
||||
|
||||
// Set initial value
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
const interval = setInterval(
|
||||
() => setElapsedSeconds(elapsedFrom(startTime)),
|
||||
1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, isRunning]);
|
||||
|
||||
+18
-7
@@ -399,19 +399,30 @@ function GenerateChallengeSection() {
|
||||
|
||||
function UsersSection() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loadedPage, setLoadedPage] = useState<number | null>(null);
|
||||
// Derived rather than a flag flipped inside the effect: a page change puts
|
||||
// the list back into its loading state on the same render that requests it.
|
||||
const loading = loadedPage !== page;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
listUsers(page, 20)
|
||||
.then((res) => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await listUsers(page, 20);
|
||||
if (cancelled) return;
|
||||
setUsers(res.data);
|
||||
setTotalPages(res.totalPages);
|
||||
})
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setLoading(false));
|
||||
} catch {
|
||||
if (!cancelled) setUsers([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoadedPage(page);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,16 +29,28 @@ export default function AsyncMatchLeaderboard() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [data, setData] = useState<AsyncLeaderboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadedMatchId, setLoadedMatchId] = useState<string | null>(null);
|
||||
const loading = loadedMatchId !== (matchId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
setLoading(true);
|
||||
getAsyncLeaderboard(matchId)
|
||||
.then(setData)
|
||||
.catch((err) => setError(err?.response?.data?.message ?? 'Failed to load'))
|
||||
.finally(() => setLoading(false));
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getAsyncLeaderboard(matchId);
|
||||
if (!cancelled) setData(res);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
if (!cancelled) setError(message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (!cancelled) setLoadedMatchId(matchId);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [matchId]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
+8
-4
@@ -62,10 +62,6 @@ export default function Chat() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (friends.length === 0) {
|
||||
fetchFriends();
|
||||
}
|
||||
|
||||
if (friendId) {
|
||||
connect();
|
||||
openConversation(friendId);
|
||||
@@ -77,6 +73,14 @@ export default function Chat() {
|
||||
};
|
||||
}, [user, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
|
||||
|
||||
// Kept separate from the socket lifecycle above: folding friends.length into
|
||||
// that effect's deps would tear down and rebuild the socket on every change
|
||||
// to the friend list.
|
||||
useEffect(() => {
|
||||
if (authLoading || !user) return;
|
||||
if (friends.length === 0) fetchFriends();
|
||||
}, [authLoading, user, friends.length, fetchFriends]);
|
||||
|
||||
// Scroll to bottom on new messages if user hasn't scrolled up
|
||||
useEffect(() => {
|
||||
if (isAtBottom.current) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
|
||||
import { Loader2, Shuffle, Filter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
+21
-15
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -32,7 +32,9 @@ export default function GameNight() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [tab, setTab] = useState<'browse' | 'create'>('browse');
|
||||
const [openRooms, setOpenRooms] = useState<GameNightRoom[]>([]);
|
||||
const [loadingRooms, setLoadingRooms] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [settledToken, setSettledToken] = useState(-1);
|
||||
const loadingRooms = settledToken !== refreshToken;
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Create form state
|
||||
@@ -49,20 +51,24 @@ export default function GameNight() {
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [joinError, setJoinError] = useState('');
|
||||
|
||||
const fetchRooms = async () => {
|
||||
setLoadingRooms(true);
|
||||
try {
|
||||
const rooms = await getOpenRooms();
|
||||
setOpenRooms(rooms);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoadingRooms(false);
|
||||
};
|
||||
const refreshRooms = useCallback(() => setRefreshToken((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const rooms = await getOpenRooms();
|
||||
if (!cancelled) setOpenRooms(rooms);
|
||||
} catch {
|
||||
// Leave the list as-is; the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setSettledToken(refreshToken);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
const handleSelectMovieA = (movie: TmdbMovieResult) => {
|
||||
setMovieA({
|
||||
@@ -194,7 +200,7 @@ export default function GameNight() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchRooms}
|
||||
onClick={refreshRooms}
|
||||
disabled={loadingRooms}
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
@@ -20,17 +20,26 @@ export default function GameNightResults() {
|
||||
const [results, setResults] = useState<GameNightResultsType | null>(
|
||||
storeResults,
|
||||
);
|
||||
const [loading, setLoading] = useState(!storeResults);
|
||||
const [tab, setTab] = useState<RankingTab>('score');
|
||||
const [fetchSettled, setFetchSettled] = useState(false);
|
||||
const loading = !results && !fetchSettled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!results && roomId) {
|
||||
setLoading(true);
|
||||
getRoomResults(roomId)
|
||||
.then(setResults)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
if (results || !roomId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getRoomResults(roomId);
|
||||
if (!cancelled) setResults(res);
|
||||
} catch {
|
||||
// Leave results null — the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setFetchSettled(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [roomId, results]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button-variants';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
|
||||
+46
-21
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button-variants';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -22,15 +23,23 @@ function formatTime(seconds: number) {
|
||||
|
||||
export default function Profile() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the user object: loadUser() rebuilds that object
|
||||
// literal on every call, which would otherwise re-trigger the fetches below.
|
||||
const userId = user?.id;
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [gameHistory, setGameHistory] = useState<GameHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statsMode, setStatsMode] = useState<'all' | 'daily'>('all');
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [historySettled, setHistorySettled] = useState(false);
|
||||
const [statsSettledMode, setStatsSettledMode] = useState<string | null>(null);
|
||||
// The page skeleton covers the first load of both requests; the dimmed-tile
|
||||
// state only applies to a later mode switch.
|
||||
const loading = !historySettled || statsSettledMode === null;
|
||||
const statsLoading =
|
||||
statsSettledMode !== null && statsSettledMode !== statsMode;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
@@ -40,31 +49,47 @@ export default function Profile() {
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState('');
|
||||
|
||||
// Game history is independent of statsMode, so it is fetched once.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
Promise.all([
|
||||
getUserStats(statsMode).catch(() => null),
|
||||
getGameHistory(1, 10).catch(() => null),
|
||||
])
|
||||
.then(([s, h]) => {
|
||||
setStats(s);
|
||||
if (h) setGameHistory(h.data);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, authLoading, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const h = await getGameHistory(1, 10);
|
||||
if (!cancelled && h) setGameHistory(h.data);
|
||||
} catch {
|
||||
// Leave history empty; the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setHistorySettled(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, navigate]);
|
||||
|
||||
// Stats re-fetch on first load and on every mode switch.
|
||||
useEffect(() => {
|
||||
if (!user || loading) return;
|
||||
setStatsLoading(true);
|
||||
getUserStats(statsMode)
|
||||
.then((s) => setStats(s))
|
||||
.catch(() => {})
|
||||
.finally(() => setStatsLoading(false));
|
||||
}, [statsMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (authLoading || !userId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await getUserStats(statsMode);
|
||||
if (!cancelled) setStats(s);
|
||||
} catch {
|
||||
if (!cancelled) setStats(null);
|
||||
} finally {
|
||||
if (!cancelled) setStatsSettledMode(statsMode);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, statsMode]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
|
||||
+38
-23
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DifficultyBadge, getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
|
||||
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
@@ -57,7 +58,8 @@ export default function Versus() {
|
||||
const navigate = useNavigate();
|
||||
const [matches, setMatches] = useState<VersusMatch[]>([]);
|
||||
const [myMatches, setMyMatches] = useState<VersusMatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [settledKey, setSettledKey] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joining, setJoining] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
@@ -73,22 +75,15 @@ export default function Versus() {
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [joinError, setJoinError] = useState('');
|
||||
|
||||
const fetchMatches = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [open, mine] = await Promise.all([
|
||||
getWaitingMatches(user?.id, filterMode === 'all' ? undefined : filterMode),
|
||||
getMyWaitingMatches(),
|
||||
]);
|
||||
setMatches(open);
|
||||
setMyMatches(mine);
|
||||
} catch {
|
||||
setMatches([]);
|
||||
setMyMatches([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const userId = user?.id;
|
||||
// One key covers both triggers: changing the filter and pressing refresh each
|
||||
// produce a new key, so `loading` flips true on the same render as the request.
|
||||
const requestKey = `${filterMode}:${refreshToken}`;
|
||||
const loading = settledKey !== requestKey;
|
||||
|
||||
// A manual refresh bumps this token; the effect below owns the actual fetch,
|
||||
// so nothing sets loading state synchronously from inside an effect.
|
||||
const refreshMatches = useCallback(() => setRefreshToken((t) => t + 1), []);
|
||||
|
||||
const handleCancel = async (matchId: string) => {
|
||||
setCancelling(matchId);
|
||||
@@ -104,12 +99,32 @@ export default function Versus() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchMatches();
|
||||
}, [user, authLoading, navigate, filterMode]);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [open, mine] = await Promise.all([
|
||||
getWaitingMatches(userId, filterMode === 'all' ? undefined : filterMode),
|
||||
getMyWaitingMatches(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setMatches(open);
|
||||
setMyMatches(mine);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setMatches([]);
|
||||
setMyMatches([]);
|
||||
} finally {
|
||||
if (!cancelled) setSettledKey(requestKey);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, navigate, filterMode, requestKey]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
@@ -365,7 +380,7 @@ export default function Versus() {
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Open Matches</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchMatches}>
|
||||
<Button variant="ghost" size="sm" onClick={refreshMatches}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user