mc-dashboard/app/api/mods/updates/route.ts
hurkicorgi f9ae1afac1 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>
2026-04-13 05:11:17 -06:00

87 lines
2.5 KiB
TypeScript

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