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

@ -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<SearchResult[]> {
const data = await res.json();
return data.hits.map((h: Record<string, unknown>) => ({
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,
}));
}

23
lib/time.ts Normal file
View file

@ -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]}`;
}