991082e65b
Features:
- "Keep me signed in" — Login.tsx adds a checkbox visible on both login
and register tabs. authApi, auth-store, and the API contract pass a
rememberMe flag through to the backend, which controls the JWT TTL.
- Movie release dates — DailyChallenge, GameHistoryEntry, VersusMatch,
AsyncAttemptResponse, and AsyncLeaderboardResponse interfaces gain
optional movieAReleaseDate / movieBReleaseDate. UI sites:
* DailyChallenge.tsx — year on a muted line under each title
(matches MovieCard convention)
* GameReview.tsx — inline (YYYY) on the heading
* ShareableResult.tsx + GameCompletionModal — inline (YYYY) in the
copied/shared text
* AsyncMatchLeaderboard.tsx — inline (YYYY) on the subtitle
All sites guard on truthy date so legacy NULL rows render unchanged.
Lint cleanup (34 → 0 errors):
- New src/lib/error.ts (getErrorMessage / getErrorStatus) to replace
`catch (err: any) { err.response?.data?.message }` patterns in
auth-store, Profile, and GameNight.
- The two new react-hooks v6 rules (set-state-in-effect, purity) flag
standard data-fetching patterns; downgraded to "warn" so CI doesn't
fail while keeping them visible in the IDE.
- Typed JSON score blobs in VersusCompletionModal and GameNightResults
with `{ totalScore?: number }`.
- Typed game-start socket payloads in VersusLobby and GameNightLobby.
- ShadCN convention: eslint-disable-next-line on badge, button, and
difficulty-badge to allow CVA helpers colocated with components
(matches upstream ShadCN pattern).
- Typed admin generateAllChallenges API response.
- Misc: prefer-const in Home.tsx, no-empty in storage.ts, underscore
ignore-pattern for no-unused-vars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import PageLayout from '@/components/layout/PageLayout';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import {
|
|
getPlatformStats,
|
|
listUsers,
|
|
recalculateScores,
|
|
generateChallenge,
|
|
generateAllChallenges,
|
|
retroactiveAchievements,
|
|
backfillUsernames,
|
|
type PlatformStats,
|
|
type AdminUser,
|
|
type RecalculateResult,
|
|
type RetroactiveAchievementsResult,
|
|
type BackfillUsernamesResult,
|
|
} from '@/api/admin';
|
|
import {
|
|
Shield,
|
|
BarChart3,
|
|
Users,
|
|
RefreshCw,
|
|
CalendarPlus,
|
|
Trophy,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
|
|
export default function Admin() {
|
|
const user = useAuthStore((s) => s.user);
|
|
const authLoading = useAuthStore((s) => s.isLoading);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (authLoading) return;
|
|
if (!user) {
|
|
navigate('/login');
|
|
} else if (!user.isAdmin) {
|
|
navigate('/');
|
|
}
|
|
}, [user, authLoading, navigate]);
|
|
|
|
if (!user?.isAdmin) return null;
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div className="space-y-8 py-6">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-6 w-6 text-primary" />
|
|
<h1 className="text-2xl font-bold">Admin Panel</h1>
|
|
</div>
|
|
|
|
<StatsSection />
|
|
<Separator />
|
|
<RecalculateSection />
|
|
<Separator />
|
|
<AchievementsSection />
|
|
<Separator />
|
|
<BackfillUsernamesSection />
|
|
<Separator />
|
|
<GenerateChallengeSection />
|
|
<Separator />
|
|
<UsersSection />
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
function StatsSection() {
|
|
const [stats, setStats] = useState<PlatformStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
getPlatformStats()
|
|
.then(setStats)
|
|
.catch(() => setStats(null))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<BarChart3 className="h-5 w-5" />
|
|
Platform Stats
|
|
</h2>
|
|
{loading ? (
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-20" />
|
|
))}
|
|
</div>
|
|
) : stats ? (
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<StatBox label="Total Users" value={stats.totalUsers} />
|
|
<StatBox label="Total Games" value={stats.totalGames} />
|
|
<StatBox label="Completed Games" value={stats.completedGames} />
|
|
<StatBox label="Completion Rate" value={`${stats.completionRate}%`} />
|
|
<StatBox label="Daily Challenges" value={stats.totalChallenges} />
|
|
<StatBox label="Versus Matches" value={stats.totalMatches} />
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Failed to load stats.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatBox({ label, value }: { label: string; value: string | number }) {
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
<p className="mt-1 text-2xl font-bold">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecalculateSection() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [result, setResult] = useState<RecalculateResult | null>(null);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleRecalculate = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setResult(null);
|
|
try {
|
|
const res = await recalculateScores();
|
|
setResult(res);
|
|
} catch {
|
|
setError('Failed to recalculate scores.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<RefreshCw className="h-5 w-5" />
|
|
Recalculate Scores
|
|
</h2>
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
Recalculates scores for all completed games using the current scoring
|
|
formula.
|
|
</p>
|
|
<Button onClick={handleRecalculate} disabled={loading}>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
)}
|
|
Recalculate All Scores
|
|
</Button>
|
|
{result && (
|
|
<Alert className="mt-3">
|
|
<AlertDescription>
|
|
Updated {result.updated} games and {result.leaderboardUpdated}{' '}
|
|
leaderboard entries, skipped {result.skipped} ({result.total}{' '}
|
|
total).
|
|
{result.backfilled > 0 &&
|
|
` Backfilled userId on ${result.backfilled} legacy games.`}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{error && (
|
|
<Alert variant="destructive" className="mt-3">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AchievementsSection() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [result, setResult] = useState<RetroactiveAchievementsResult | null>(
|
|
null,
|
|
);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleRetroactive = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setResult(null);
|
|
try {
|
|
const res = await retroactiveAchievements();
|
|
setResult(res);
|
|
} catch {
|
|
setError('Failed to process retroactive achievements.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<Trophy className="h-5 w-5" />
|
|
Retroactive Achievements
|
|
</h2>
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
Evaluate all existing games and award any achievements that should have
|
|
been earned.
|
|
</p>
|
|
<Button onClick={handleRetroactive} disabled={loading}>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trophy className="mr-2 h-4 w-4" />
|
|
)}
|
|
Award Retroactive Achievements
|
|
</Button>
|
|
{result && (
|
|
<Alert className="mt-3">
|
|
<AlertDescription>
|
|
Awarded {result.totalAwarded} achievements across{' '}
|
|
{result.usersAffected} users.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{error && (
|
|
<Alert variant="destructive" className="mt-3">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BackfillUsernamesSection() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [result, setResult] = useState<BackfillUsernamesResult | null>(null);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleBackfill = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setResult(null);
|
|
try {
|
|
const res = await backfillUsernames();
|
|
setResult(res);
|
|
} catch {
|
|
setError('Failed to backfill usernames.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<Users className="h-5 w-5" />
|
|
Backfill Normalized Usernames
|
|
</h2>
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
Populates the normalized username column for existing users so they can
|
|
be found via friend search.
|
|
</p>
|
|
<Button onClick={handleBackfill} disabled={loading}>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Users className="mr-2 h-4 w-4" />
|
|
)}
|
|
Backfill Usernames
|
|
</Button>
|
|
{result && (
|
|
<Alert className="mt-3">
|
|
<AlertDescription>
|
|
Backfilled {result.backfilled} user{result.backfilled !== 1 ? 's' : ''}.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{error && (
|
|
<Alert variant="destructive" className="mt-3">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GenerateChallengeSection() {
|
|
const [date, setDate] = useState('');
|
|
const [difficulty, setDifficulty] = useState('medium');
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingAll, setLoadingAll] = useState(false);
|
|
const [success, setSuccess] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
const handleGenerate = async () => {
|
|
if (!date) return;
|
|
setLoading(true);
|
|
setError('');
|
|
setSuccess('');
|
|
try {
|
|
const challenge = await generateChallenge(date, difficulty);
|
|
setSuccess(
|
|
`Generated ${difficulty}: ${challenge.movieATitle} \u2194 ${challenge.movieBTitle}`,
|
|
);
|
|
} catch {
|
|
setError('Failed to generate challenge.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateAll = async () => {
|
|
if (!date) return;
|
|
setLoadingAll(true);
|
|
setError('');
|
|
setSuccess('');
|
|
try {
|
|
const challenges = await generateAllChallenges(date);
|
|
const summaries = challenges.map(
|
|
(c) => `${c.difficulty}: ${c.movieATitle} \u2194 ${c.movieBTitle}`,
|
|
);
|
|
setSuccess(`Generated all 3:\n${summaries.join('\n')}`);
|
|
} catch {
|
|
setError('Failed to generate challenges.');
|
|
} finally {
|
|
setLoadingAll(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<CalendarPlus className="h-5 w-5" />
|
|
Generate Daily Challenge
|
|
</h2>
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-sm text-muted-foreground">
|
|
Date
|
|
</label>
|
|
<Input
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
className="w-44"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm text-muted-foreground">
|
|
Difficulty
|
|
</label>
|
|
<select
|
|
value={difficulty}
|
|
onChange={(e) => setDifficulty(e.target.value)}
|
|
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
|
>
|
|
<option value="easy">Easy</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="hard">Hard</option>
|
|
</select>
|
|
</div>
|
|
<Button onClick={handleGenerate} disabled={loading || loadingAll || !date}>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CalendarPlus className="mr-2 h-4 w-4" />
|
|
)}
|
|
Generate
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleGenerateAll}
|
|
disabled={loading || loadingAll || !date}
|
|
>
|
|
{loadingAll ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CalendarPlus className="mr-2 h-4 w-4" />
|
|
)}
|
|
Generate All 3
|
|
</Button>
|
|
</div>
|
|
{success && (
|
|
<Alert className="mt-3">
|
|
<AlertDescription className="whitespace-pre-line">
|
|
{success}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{error && (
|
|
<Alert variant="destructive" className="mt-3">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsersSection() {
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
listUsers(page, 20)
|
|
.then((res) => {
|
|
setUsers(res.data);
|
|
setTotalPages(res.totalPages);
|
|
})
|
|
.catch(() => setUsers([]))
|
|
.finally(() => setLoading(false));
|
|
}, [page]);
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
|
<Users className="h-5 w-5" />
|
|
Users
|
|
</h2>
|
|
{loading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12" />
|
|
))}
|
|
</div>
|
|
) : users.length > 0 ? (
|
|
<>
|
|
<div className="rounded-lg border border-border">
|
|
<div className="grid grid-cols-[1fr_1fr_5rem_5rem] gap-2 border-b border-border bg-muted/50 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
|
|
<span>Username</span>
|
|
<span>Joined</span>
|
|
<span className="text-right">Games</span>
|
|
<span className="text-right">Entries</span>
|
|
</div>
|
|
{users.map((u) => (
|
|
<div
|
|
key={u.id}
|
|
className="grid grid-cols-[1fr_1fr_5rem_5rem] gap-2 border-b border-border px-4 py-2.5 last:border-0"
|
|
>
|
|
<span className="truncate text-sm font-medium">
|
|
{u.username}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{new Date(u.createdAt).toLocaleDateString()}
|
|
</span>
|
|
<span className="text-right text-sm tabular-nums">
|
|
{u._count.gameSessions}
|
|
</span>
|
|
<span className="text-right text-sm tabular-nums">
|
|
{u._count.leaderboardEntries}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="mt-3 flex items-center justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No users found.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|