2026-04-13 00:46:58 -06:00
|
|
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
|
|
|
import { join } from "path";
|
|
|
|
|
import { MODS_DIR, CLIENT_MODS_DIR } from "./constants";
|
|
|
|
|
import { getModDetails } from "./mods";
|
|
|
|
|
|
|
|
|
|
const API = "https://api.modrinth.com/v2";
|
|
|
|
|
const GAME_VERSION = "1.20.1";
|
|
|
|
|
const LOADER = "forge";
|
|
|
|
|
|
|
|
|
|
export type ModSide = "client" | "server" | "both";
|
|
|
|
|
|
|
|
|
|
export type SearchResult = {
|
|
|
|
|
slug: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
icon_url: string;
|
|
|
|
|
downloads: number;
|
|
|
|
|
project_id: string;
|
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
|
|
|
author: string;
|
|
|
|
|
date_modified: string;
|
|
|
|
|
follows: number;
|
2026-04-13 00:46:58 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ModDownload = {
|
|
|
|
|
projectId: string;
|
|
|
|
|
versionId: string;
|
|
|
|
|
title: string;
|
|
|
|
|
filename: string;
|
|
|
|
|
url: string;
|
|
|
|
|
isDependency: boolean;
|
|
|
|
|
alreadyInstalled: boolean;
|
|
|
|
|
side: ModSide;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ResolveResult = {
|
|
|
|
|
toInstall: ModDownload[];
|
|
|
|
|
skipped: ModDownload[];
|
|
|
|
|
conflicts: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Search ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function searchMods(query: string): Promise<SearchResult[]> {
|
|
|
|
|
const facets = JSON.stringify([
|
|
|
|
|
[`categories:${LOADER}`],
|
|
|
|
|
[`versions:${GAME_VERSION}`],
|
|
|
|
|
["project_type:mod"],
|
|
|
|
|
]);
|
|
|
|
|
const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`;
|
|
|
|
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`);
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return data.hits.map((h: Record<string, unknown>) => ({
|
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
|
|
|
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,
|
2026-04-13 00:46:58 -06:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Get project details (title + side info) ─────────────────
|
|
|
|
|
|
|
|
|
|
type ProjectDetails = {
|
|
|
|
|
title: string;
|
|
|
|
|
client_side: string;
|
|
|
|
|
server_side: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function getProjectDetails(
|
|
|
|
|
projectId: string
|
|
|
|
|
): Promise<ProjectDetails | null> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API}/project/${projectId}`, {
|
|
|
|
|
headers: {
|
|
|
|
|
"User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return {
|
|
|
|
|
title: data.title || projectId,
|
|
|
|
|
client_side: data.client_side || "optional",
|
|
|
|
|
server_side: data.server_side || "optional",
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function classifySide(
|
|
|
|
|
client_side: string,
|
|
|
|
|
server_side: string
|
|
|
|
|
): ModSide {
|
|
|
|
|
if (server_side === "unsupported") return "client";
|
|
|
|
|
if (client_side === "unsupported") return "server";
|
|
|
|
|
return "both";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Get latest version for a project ────────────────────────
|
|
|
|
|
|
|
|
|
|
type VersionFile = { url: string; filename: string };
|
|
|
|
|
type VersionDep = {
|
|
|
|
|
project_id: string | null;
|
|
|
|
|
dependency_type: string;
|
|
|
|
|
};
|
|
|
|
|
type VersionData = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
files: VersionFile[];
|
|
|
|
|
dependencies: VersionDep[];
|
|
|
|
|
};
|
|
|
|
|
|
Pass 3 first slice: mod update action, error boundaries, a11y, palette
- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
download latest → swap old jar → restart + verify (if server-side) →
rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
plus "Update all (N)" in the installed list header. Reuses the existing
install timeline UI (same event shape). SSE reader extracted as
consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
ModManager side filter; aria-label on admin TabsList; skip-to-content
link in Navbar targeting <main id="main"> on public + admin pages;
role/aria-live on install/update timeline; global Esc in ModManager
clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
Navigate admin tabs, toggle theme, start/stop/restart server, create
backup, re-check mod updates, jump to any cached mod/player/snapshot/
backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
active tab live.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:30:23 -06:00
|
|
|
export async function getLatestVersion(
|
2026-04-13 00:46:58 -06:00
|
|
|
projectId: string
|
|
|
|
|
): Promise<VersionData | null> {
|
|
|
|
|
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
|
|
|
|
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
|
|
|
|
|
const versions: VersionData[] = await res.json();
|
|
|
|
|
return versions.length > 0 ? versions[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Resolve dependencies ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function resolveDependencies(
|
|
|
|
|
projectIds: string[],
|
|
|
|
|
titles: Record<string, string>
|
|
|
|
|
): Promise<ResolveResult> {
|
|
|
|
|
// Cache installed mods once for the entire resolution
|
|
|
|
|
const installed = getModDetails();
|
|
|
|
|
const toInstall: ModDownload[] = [];
|
|
|
|
|
const skipped: ModDownload[] = [];
|
|
|
|
|
const conflicts: string[] = [];
|
|
|
|
|
const visited = new Set<string>();
|
|
|
|
|
const queue = projectIds.map((id) => ({ id, isDep: false }));
|
|
|
|
|
|
|
|
|
|
while (queue.length > 0) {
|
|
|
|
|
const item = queue.shift()!;
|
|
|
|
|
if (visited.has(item.id)) continue;
|
|
|
|
|
visited.add(item.id);
|
|
|
|
|
|
|
|
|
|
const [version, details] = await Promise.all([
|
|
|
|
|
getLatestVersion(item.id),
|
|
|
|
|
getProjectDetails(item.id),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (!version || version.files.length === 0) {
|
|
|
|
|
if (!item.isDep) {
|
|
|
|
|
conflicts.push(
|
|
|
|
|
`"${titles[item.id] || item.id}" has no Forge 1.20.1 version available`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const title = titles[item.id] || details?.title || item.id;
|
|
|
|
|
const side = details
|
|
|
|
|
? classifySide(details.client_side, details.server_side)
|
|
|
|
|
: "both";
|
|
|
|
|
const file = version.files[0];
|
|
|
|
|
|
|
|
|
|
const mod: ModDownload = {
|
|
|
|
|
projectId: item.id,
|
|
|
|
|
versionId: version.id,
|
|
|
|
|
title,
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
url: file.url,
|
|
|
|
|
isDependency: item.isDep,
|
|
|
|
|
alreadyInstalled: false,
|
|
|
|
|
side,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if already installed (by filename as rough proxy)
|
|
|
|
|
const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase();
|
|
|
|
|
const isInstalled = installed.some(
|
|
|
|
|
(m) =>
|
|
|
|
|
m.filename === file.filename ||
|
|
|
|
|
m.filename.toLowerCase().startsWith(filenameBase)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isInstalled) {
|
|
|
|
|
mod.alreadyInstalled = true;
|
|
|
|
|
skipped.push(mod);
|
|
|
|
|
} else {
|
|
|
|
|
toInstall.push(mod);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Queue required dependencies
|
|
|
|
|
for (const dep of version.dependencies) {
|
|
|
|
|
if (
|
|
|
|
|
dep.dependency_type === "required" &&
|
|
|
|
|
dep.project_id &&
|
|
|
|
|
!visited.has(dep.project_id)
|
|
|
|
|
) {
|
|
|
|
|
queue.push({ id: dep.project_id, isDep: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { toInstall, skipped, conflicts };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Download a mod ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function downloadMod(
|
|
|
|
|
url: string,
|
|
|
|
|
filename: string,
|
|
|
|
|
side: ModSide = "both"
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`);
|
|
|
|
|
|
|
|
|
|
const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR;
|
|
|
|
|
if (side === "client" && !existsSync(CLIENT_MODS_DIR)) {
|
|
|
|
|
mkdirSync(CLIENT_MODS_DIR, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
|
|
|
writeFileSync(join(targetDir, filename), buffer);
|
|
|
|
|
}
|