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:
parent
6c91f7fef0
commit
f9ae1afac1
12 changed files with 334 additions and 76 deletions
87
app/api/mods/updates/route.ts
Normal file
87
app/api/mods/updates/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue