UX polish pass 2: toasts, optimistic updates, mod update detection

- Install sonner; <Toaster> mounted in Providers, auto-tracks theme.
- Toasts replace inline result Alerts across ServerControls, PlayerManager,
  BackupManager, ModManager (install/remove/restore/delete/start/stop).
- PlayerManager: optimistic op/deop/whitelist/ban/pardon via onMutate +
  rollback; UI updates instantly before RCON round-trip.
- Modrinth search results now show author + "updated Xd ago" with full
  timestamp on hover; downloads on its own row.
- New /api/mods/updates endpoint: per-installed-mod Modrinth latest-version
  lookup (parallel, 30min memo). Amber "Update available" badge rendered
  next to installed mod rows when filenames differ.
- PlayerAvatar + Modrinth icons migrated to next/image (unoptimized, size
  hints) — fewer layout shifts.
- Login page surfaces ?error= + NextAuth error codes (CredentialsSignin,
  SessionRequired, etc.), preserves callbackUrl, adds autocomplete hints
  and role="alert". Wrapped in Suspense per Next 16 requirement.
- Snapshots + backups show relative "Xh ago" with exact timestamp on hover
  via new lib/time.ts helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 05:11:17 -06:00
parent 6c91f7fef0
commit f9ae1afac1
12 changed files with 334 additions and 76 deletions

View file

@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import { auth } from "@/lib/auth";
import { MOD_METADATA_FILE } from "@/lib/constants";
import { memoAsync } from "@/lib/cache";
export const dynamic = "force-dynamic";
const API = "https://api.modrinth.com/v2";
const GAME_VERSION = "1.20.1";
const LOADER = "forge";
const UA = "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)";
type ModMetadataEntry = { projectId: string; side: string };
type UpdateInfo = {
filename: string;
projectId: string;
latestFilename: string;
latestVersionId: string;
dateModified: string;
hasUpdate: boolean;
};
async function checkUpdates(): Promise<UpdateInfo[]> {
let metadata: Record<string, ModMetadataEntry>;
try {
metadata = JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return [];
}
const entries = Object.entries(metadata).filter(([, m]) => m?.projectId);
if (entries.length === 0) return [];
const results = await Promise.all(
entries.map(async ([filename, entry]) => {
try {
const url = `${API}/project/${entry.projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
const res = await fetch(url, {
headers: { "User-Agent": UA },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const versions: Array<{
id: string;
date_published: string;
files: Array<{ filename: string }>;
}> = await res.json();
const latest = versions[0];
if (!latest || !latest.files?.[0]) return null;
const latestFilename = latest.files[0].filename;
return {
filename,
projectId: entry.projectId,
latestFilename,
latestVersionId: latest.id,
dateModified: latest.date_published,
hasUpdate: latestFilename !== filename,
} satisfies UpdateInfo;
} catch {
return null;
}
})
);
return results.filter((r): r is UpdateInfo => r !== null && r.hasUpdate);
}
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
try {
const updates = await memoAsync("mods:updates", 30 * 60 * 1000, checkUpdates);
return NextResponse.json(updates, {
headers: { "Cache-Control": "private, max-age=300" },
});
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message },
{ status: 500 }
);
}
}

View file

@ -1,18 +1,32 @@
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
const ERROR_MESSAGES: Record<string, string> = {
CredentialsSignin: "Invalid username or password.",
SessionRequired: "Please sign in to continue.",
Verification: "Sign-in link is invalid or expired.",
AccessDenied: "You don't have access.",
Configuration: "Auth configuration error — contact the server admin.",
};
function LoginInner() {
const router = useRouter();
const params = useSearchParams();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
const qErr = params.get("error");
if (qErr) setError(ERROR_MESSAGES[qErr] || `Sign-in failed (${qErr}).`);
}, [params]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
@ -27,10 +41,11 @@ export default function LoginPage() {
});
if (res?.error) {
setError("Invalid credentials");
setError(ERROR_MESSAGES[res.error] || "Invalid credentials.");
setLoading(false);
} else {
router.push("/admin");
const callback = params.get("callbackUrl") || "/admin";
router.push(callback);
}
}
@ -44,16 +59,34 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" name="username" type="text" required />
<Input
id="username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{error && (
<p className="text-destructive text-sm text-center">{error}</p>
<div
role="alert"
className="rounded-md border border-red-500/30 bg-red-500/5 p-2.5 text-destructive text-sm text-center"
>
{error}
</div>
)}
<Button type="submit" disabled={loading} className="w-full">
@ -74,3 +107,11 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
);
}

View file

@ -2,7 +2,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { useEffect, useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@ -16,9 +17,29 @@ export function Providers({ children }: { children: React.ReactNode }) {
})
);
const [theme, setTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const sync = () => {
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
};
sync();
const obs = new MutationObserver(sync);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => obs.disconnect();
}, []);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster
theme={theme}
position="bottom-right"
richColors
closeButton
toastOptions={{ duration: 4000 }}
/>
</QueryClientProvider>
</SessionProvider>
);
}