Phase5
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal 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} />;
|
||||
}
|
||||
222
apps/web/src/components/servers/server-addons.tsx
Normal file
222
apps/web/src/components/servers/server-addons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
81
apps/web/src/lib/api/addons.ts
Normal file
81
apps/web/src/lib/api/addons.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user