From f9ae1afac13489539ecb7cdc1137641a9ae10b1a Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 05:11:17 -0600 Subject: [PATCH] UX polish pass 2: toasts, optimistic updates, mod update detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install sonner; 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) --- app/api/mods/updates/route.ts | 87 +++++++++++++++++++++++++++++++++++ app/login/page.tsx | 57 +++++++++++++++++++---- app/providers.tsx | 25 +++++++++- bun.lock | 3 ++ components/BackupManager.tsx | 30 ++++++------ components/ModManager.tsx | 73 +++++++++++++++++++++++++---- components/PlayerAvatar.tsx | 6 +-- components/PlayerManager.tsx | 62 ++++++++++++++++++------- components/ServerControls.tsx | 25 ++++------ lib/modrinth.ts | 18 +++++--- lib/time.ts | 23 +++++++++ package.json | 1 + 12 files changed, 334 insertions(+), 76 deletions(-) create mode 100644 app/api/mods/updates/route.ts create mode 100644 lib/time.ts diff --git a/app/api/mods/updates/route.ts b/app/api/mods/updates/route.ts new file mode 100644 index 0000000..deaec65 --- /dev/null +++ b/app/api/mods/updates/route.ts @@ -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 { + let metadata: Record; + 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 } + ); + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 47b571e..64394d8 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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 = { + 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) { 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() {
- +
- +
{error && ( -

{error}

+
+ {error} +
)} - {/* Feedback */} - {result && ( - - - {result.message} - - - )} - {/* Player lists */} diff --git a/components/ServerControls.tsx b/components/ServerControls.tsx index ffc29be..8eadc4f 100644 --- a/components/ServerControls.tsx +++ b/components/ServerControls.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Separator } from "@/components/ui/separator"; @@ -51,16 +52,21 @@ export function ServerControls() { } return { ...(await res.json()), act }; }, - onSuccess: () => { + onSuccess: (_, act) => { + toast.success(`${ACTION_LABEL[act]} command sent`, { + description: "Status will update in a few seconds.", + }); setTimeout( () => queryClient.invalidateQueries({ queryKey: ["status"] }), 3000 ); }, + onError: (err, act) => { + toast.error(`${ACTION_LABEL[act]} failed`, { description: err.message }); + }, }); const isOnline = status?.online ?? false; - const lastAction = action.data?.act as Action | undefined; const trigger = (act: Action) => { if (act === "start") { @@ -179,20 +185,7 @@ export function ServerControls() { )} - {action.isSuccess && !action.isPending && ( - - - {lastAction ? `${ACTION_LABEL[lastAction]} command sent.` : "Command sent."} Status updates in a few seconds. - - - )} - {action.isError && ( - - - {action.error.message} - - - )} + {/* success/error surfaced via toast */} diff --git a/lib/modrinth.ts b/lib/modrinth.ts index 121d6e3..82faa6e 100644 --- a/lib/modrinth.ts +++ b/lib/modrinth.ts @@ -16,6 +16,9 @@ export type SearchResult = { icon_url: string; downloads: number; project_id: string; + author: string; + date_modified: string; + follows: number; }; export type ModDownload = { @@ -53,12 +56,15 @@ export async function searchMods(query: string): Promise { const data = await res.json(); return data.hits.map((h: Record) => ({ - slug: h.slug, - title: h.title, - description: h.description, - icon_url: h.icon_url || "", - downloads: h.downloads, - project_id: h.project_id, + slug: h.slug as string, + title: h.title as string, + description: h.description as string, + icon_url: (h.icon_url as string) || "", + downloads: (h.downloads as number) ?? 0, + project_id: h.project_id as string, + author: (h.author as string) || "", + date_modified: (h.date_modified as string) || "", + follows: (h.follows as number) ?? 0, })); } diff --git a/lib/time.ts b/lib/time.ts new file mode 100644 index 0000000..af014e2 --- /dev/null +++ b/lib/time.ts @@ -0,0 +1,23 @@ +export function timeAgo(iso: string | number | Date): string { + const d = typeof iso === "string" || typeof iso === "number" ? new Date(iso) : iso; + const sec = Math.round((Date.now() - d.getTime()) / 1000); + if (!isFinite(sec) || sec < 0) return ""; + if (sec < 45) return "just now"; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.round(hr / 24); + if (day < 30) return `${day}d ago`; + const mo = Math.round(day / 30); + if (mo < 12) return `${mo}mo ago`; + const yr = Math.round(mo / 12); + return `${yr}y ago`; +} + +export function formatBytes(n: number): string { + if (!isFinite(n) || n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + return `${(n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/package.json b/package.json index 71f4a4d..fd1db5b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" },