Files
movieloop-frontend/src/pages/Admin.tsx
T
TehRiehlDeal 991082e65b Add Keep-Me-Signed-In, movie release dates, and lint cleanup
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>
2026-05-08 16:40:23 -07:00

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>
);
}