Phase5
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 13:00:01 +02:00
parent 4262464cd5
commit d29a02f2a4
93 changed files with 1875 additions and 55 deletions

View File

@@ -192,7 +192,8 @@
"properties": "Eigenschaften",
"players": "Spieler",
"worlds": "Welten",
"backups": "Backups"
"backups": "Backups",
"addons": "Add-ons"
},
"console": {
"loading": "Wird geladen…",
@@ -317,6 +318,23 @@
"created": "Erstellt",
"actions": "Aktionen",
"empty": "Noch keine Backups vorhanden."
},
"addons": {
"loading": "Wird geladen…",
"unsupported": "Add-ons werden für diese Server-Software noch nicht unterstützt. Verwenden Sie Paper oder Fabric.",
"stoppedHint": "Add-ons können nur installiert oder entfernt werden, wenn der Server gestoppt ist.",
"searchPlaceholder": "Modrinth durchsuchen…",
"search": "Suchen",
"searchError": "Katalogsuche fehlgeschlagen.",
"versionsError": "Versionen konnten nicht geladen werden.",
"installError": "Installation fehlgeschlagen.",
"removeError": "Entfernen fehlgeschlagen.",
"select": "Versionen",
"compatibleVersions": "Kompatible Versionen für {name}",
"install": "Installieren",
"remove": "Entfernen",
"installed": "Installiert",
"empty": "Noch keine Add-ons installiert."
}
}
}

View File

@@ -192,7 +192,8 @@
"properties": "Properties",
"players": "Players",
"worlds": "Worlds",
"backups": "Backups"
"backups": "Backups",
"addons": "Add-ons"
},
"console": {
"loading": "Loading…",
@@ -317,6 +318,23 @@
"created": "Created",
"actions": "Actions",
"empty": "No backups yet."
},
"addons": {
"loading": "Loading…",
"unsupported": "Add-ons are not supported for this server software yet. Use Paper or Fabric.",
"stoppedHint": "Add-ons can only be installed or removed while the server is stopped.",
"searchPlaceholder": "Search Modrinth…",
"search": "Search",
"searchError": "Catalog search failed.",
"versionsError": "Could not load versions.",
"installError": "Installation failed.",
"removeError": "Remove failed.",
"select": "Versions",
"compatibleVersions": "Compatible versions for {name}",
"install": "Install",
"remove": "Remove",
"installed": "Installed",
"empty": "No add-ons installed yet."
}
}
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerAddons } from "@/components/servers/server-addons";
interface ServerAddonsPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerAddonsPage({ params }: ServerAddonsPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerAddons serverId={id} />;
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
installAddon,
listCatalogVersions,
listInstalledAddons,
removeAddon,
searchCatalog,
type CatalogProject,
type CatalogVersion,
} from "@/lib/api/addons";
import { getServer } from "@/lib/api/servers";
interface ServerAddonsProps {
serverId: string;
}
export function ServerAddons({ serverId }: ServerAddonsProps) {
const t = useTranslations("servers.addons");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogProject[]>([]);
const [selectedProject, setSelectedProject] = useState<CatalogProject | null>(null);
const [versions, setVersions] = useState<CatalogVersion[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const refreshInstalled = useCallback(async () => {
const data = await listInstalledAddons(serverId);
return data.addons;
}, [serverId]);
const [installed, setInstalled] = useState<Awaited<ReturnType<typeof refreshInstalled>>>([]);
useEffect(() => {
void refreshInstalled().then(setInstalled).catch(() => undefined);
}, [refreshInstalled]);
const supportsAddons =
server &&
["PAPER", "PURPUR", "FABRIC", "FORGE", "NEOFORGE", "QUILT"].includes(
server.softwareFamily,
);
async function handleSearch(event: React.FormEvent) {
event.preventDefault();
if (!server || !supportsAddons || !query.trim()) {
return;
}
setBusy(true);
setError(null);
try {
const response = await searchCatalog({
q: query.trim(),
gameVersion: server.minecraftVersion,
softwareFamily: server.softwareFamily,
});
setResults(response.projects);
setSelectedProject(null);
setVersions([]);
} catch (err) {
setError(err instanceof Error ? err.message : t("searchError"));
} finally {
setBusy(false);
}
}
async function handleSelectProject(project: CatalogProject) {
setSelectedProject(project);
setBusy(true);
setError(null);
try {
const response = await listCatalogVersions(project.id, serverId);
setVersions(response.versions.filter((version) => version.compatible));
} catch (err) {
setError(err instanceof Error ? err.message : t("versionsError"));
} finally {
setBusy(false);
}
}
async function handleInstall(version: CatalogVersion) {
if (!selectedProject) {
return;
}
setBusy(true);
setError(null);
try {
await installAddon(serverId, selectedProject.id, version.id);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("installError"));
} finally {
setBusy(false);
}
}
async function handleRemove(addonId: string) {
setBusy(true);
setError(null);
try {
await removeAddon(serverId, addonId);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("removeError"));
} finally {
setBusy(false);
}
}
if (!server) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
if (!supportsAddons) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("unsupported")}</p>;
}
if (server.status !== "STOPPED") {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("stoppedHint")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<form onSubmit={(event) => void handleSearch(event)} className="flex gap-2">
<input
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
/>
<Button type="submit" disabled={busy}>
{t("search")}
</Button>
</form>
{results.length > 0 ? (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{results.map((project) => (
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-medium text-sm">{project.name}</p>
<p className="text-xs text-zinc-500 line-clamp-2">{project.description}</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleSelectProject(project)}
disabled={busy}
>
{t("select")}
</Button>
</li>
))}
</ul>
) : null}
{selectedProject && versions.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("compatibleVersions", { name: selectedProject.name })}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{versions.map((version) => (
<li key={version.id} className="flex items-center justify-between px-4 py-3 text-sm">
<span>{version.versionNumber}</span>
<Button size="sm" onClick={() => void handleInstall(version)} disabled={busy}>
{t("install")}
</Button>
</li>
))}
</ul>
</div>
) : null}
<div>
<h3 className="mb-2 text-sm font-medium">{t("installed")}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{installed.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
) : (
installed.map((addon) => (
<li key={addon.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{addon.projectName}</p>
<p className="text-xs text-zinc-500">
{addon.versionNumber} · {addon.status}
</p>
</div>
{addon.status === "INSTALLED" ? (
<Button
size="sm"
variant="ghost"
onClick={() => void handleRemove(addon.id)}
disabled={busy}
>
{t("remove")}
</Button>
) : null}
</li>
))
)}
</ul>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ const tabs = [
{ key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" },
{ key: "addons", suffix: "/addons" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,81 @@
import { apiFetch } from "./auth";
import type { SoftwareFamily } from "./servers";
export interface CatalogProject {
id: string;
slug: string;
name: string;
description: string;
projectType: string;
downloads: number;
iconUrl: string | null;
}
export interface CatalogVersion {
id: string;
projectId: string;
name: string;
versionNumber: string;
gameVersions: string[];
loaders: string[];
compatible: boolean;
publishedAt: string;
}
export interface InstalledAddon {
id: string;
projectId: string;
projectName: string;
versionNumber: string;
addonType: string;
fileName: string;
status: string;
failureReason: string | null;
}
export async function searchCatalog(input: {
q: string;
gameVersion: string;
softwareFamily: SoftwareFamily;
}): Promise<{ projects: CatalogProject[] }> {
const params = new URLSearchParams({
q: input.q,
gameVersion: input.gameVersion,
softwareFamily: input.softwareFamily,
});
return apiFetch(`/catalog/projects?${params.toString()}`);
}
export async function listCatalogVersions(
projectId: string,
serverId: string,
): Promise<{ versions: CatalogVersion[] }> {
const params = new URLSearchParams({ serverId });
return apiFetch(`/catalog/projects/${projectId}/versions?${params.toString()}`);
}
export async function listInstalledAddons(
serverId: string,
): Promise<{ addons: InstalledAddon[] }> {
return apiFetch(`/servers/${serverId}/addons`);
}
export async function installAddon(
serverId: string,
projectId: string,
versionId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons`, {
method: "POST",
body: JSON.stringify({ projectId, versionId }),
});
}
export async function removeAddon(
serverId: string,
addonId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons/${addonId}`, {
method: "DELETE",
});
}