feat: overhaul server automation, files editor, and CS2 setup workflows

This commit is contained in:
2026-02-26 21:01:00 +00:00
parent 44c439e2f9
commit 2a3ad5e78f
40 changed files with 4675 additions and 468 deletions
+458 -20
View File
@@ -1,23 +1,27 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useOutletContext, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Download,
Puzzle,
Search,
Download,
Trash2,
Star,
ToggleLeft,
ToggleRight,
Star,
Trash2,
Upload,
Store,
Plus,
Pencil,
} from 'lucide-react';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -50,9 +54,46 @@ interface SpigetResult {
external: boolean;
}
interface MarketplacePlugin {
id: string;
name: string;
slug: string;
description: string | null;
source: 'spiget' | 'manual';
externalId: string | null;
downloadUrl: string | null;
version: string | null;
updatedAt: string;
isInstalled: boolean;
installId: string | null;
installedVersion: string | null;
isActive: boolean;
installedAt: string | null;
}
interface MarketplaceResponse {
game: {
id: string;
slug: string;
name: string;
};
plugins: MarketplacePlugin[];
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
export function PluginsPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
const isMinecraft = server?.gameSlug === 'minecraft-java';
const { data: pluginsData } = useQuery({
queryKey: ['plugins', orgId, serverId],
@@ -65,29 +106,41 @@ export function PluginsPage() {
const installed = pluginsData?.plugins ?? [];
return (
<Tabs defaultValue="installed" className="space-y-4">
<TabsList>
<Tabs defaultValue="marketplace" className="space-y-4">
<TabsList className="flex h-auto w-full flex-wrap">
<TabsTrigger value="marketplace">
<Store className="mr-1.5 h-3.5 w-3.5" />
Marketplace
</TabsTrigger>
<TabsTrigger value="installed">
<Puzzle className="mr-1.5 h-3.5 w-3.5" />
Installed ({installed.length})
</TabsTrigger>
<TabsTrigger value="search">
<Search className="mr-1.5 h-3.5 w-3.5" />
Search Plugins
</TabsTrigger>
{isMinecraft && (
<TabsTrigger value="search">
<Search className="mr-1.5 h-3.5 w-3.5" />
Spiget Search
</TabsTrigger>
)}
<TabsTrigger value="manual">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Manual Install
</TabsTrigger>
</TabsList>
<TabsContent value="marketplace">
<MarketplacePlugins orgId={orgId!} serverId={serverId!} />
</TabsContent>
<TabsContent value="installed">
<InstalledPlugins installed={installed} orgId={orgId!} serverId={serverId!} />
</TabsContent>
<TabsContent value="search">
<SpigetSearch orgId={orgId!} serverId={serverId!} />
</TabsContent>
{isMinecraft && (
<TabsContent value="search">
<SpigetSearch orgId={orgId!} serverId={serverId!} />
</TabsContent>
)}
<TabsContent value="manual">
<ManualInstall orgId={orgId!} serverId={serverId!} />
@@ -96,6 +149,369 @@ export function PluginsPage() {
);
}
function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: string }) {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [downloadUrl, setDownloadUrl] = useState('');
const [version, setVersion] = useState('');
const [description, setDescription] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
queryFn: () =>
api.get<MarketplaceResponse>(
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`,
searchTerm ? { q: searchTerm } : undefined,
),
});
const installMutation = useMutation({
mutationFn: (pluginId: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
onSuccess: () => {
toast.success('Plugin installed');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin install failed'));
},
});
const uninstallMutation = useMutation({
mutationFn: (installId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}`),
onSuccess: () => {
toast.success('Plugin uninstalled');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin uninstall failed'));
},
});
const createMutation = useMutation({
mutationFn: (body: {
name: string;
slug?: string;
description?: string;
downloadUrl: string;
version?: string;
}) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
onSuccess: () => {
toast.success('Marketplace plugin added');
setCreateOpen(false);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to add marketplace plugin'));
},
});
const deleteMutation = useMutation({
mutationFn: (pluginId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${pluginId}`),
onSuccess: () => {
toast.success('Marketplace plugin removed');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to remove marketplace plugin'));
},
});
const updateMutation = useMutation({
mutationFn: (body: {
pluginId: string;
name: string;
slug?: string;
description?: string;
downloadUrl?: string;
version?: string;
}) =>
api.patch(
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${body.pluginId}`,
{
name: body.name,
slug: body.slug,
description: body.description,
downloadUrl: body.downloadUrl,
version: body.version,
},
),
onSuccess: () => {
toast.success('Marketplace plugin updated');
setEditOpen(false);
setEditingPluginId(null);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update marketplace plugin'));
},
});
const openEditDialog = (plugin: MarketplacePlugin) => {
setEditingPluginId(plugin.id);
setName(plugin.name);
setSlug(plugin.slug);
setDownloadUrl(plugin.downloadUrl ?? '');
setVersion(plugin.version ?? '');
setDescription(plugin.description ?? '');
setEditOpen(true);
};
const plugins = data?.plugins ?? [];
const gameName = data?.game.name ?? 'Game';
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold">{gameName} Marketplace</h3>
<p className="text-sm text-muted-foreground">
Oyununuza uygun eklentileri tek tıkla kur/kaldırın.
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
Plugin Ekle
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace Plugin Ekle</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate({
name,
slug: slug || undefined,
description: description || undefined,
downloadUrl,
version: version || undefined,
});
}}
>
<div className="space-y-2">
<Label>Plugin Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Download URL</Label>
<Input
type="url"
value={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
placeholder="https://example.com/plugin.jar"
required
/>
</div>
<div className="space-y-2">
<Label>Version (optional)</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Ekleniyor...' : 'Ekle'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (!open) {
setEditingPluginId(null);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace Plugin Düzenle</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!editingPluginId) return;
updateMutation.mutate({
pluginId: editingPluginId,
name,
slug: slug || undefined,
description: description || undefined,
downloadUrl: downloadUrl || undefined,
version: version || undefined,
});
}}
>
<div className="space-y-2">
<Label>Plugin Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Download URL (optional)</Label>
<Input
type="url"
value={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
placeholder="https://example.com/plugin.jar"
/>
</div>
<div className="space-y-2">
<Label>Version (optional)</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="flex gap-2">
<Input
placeholder="Plugin ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') setSearchTerm(search.trim());
}}
/>
<Button onClick={() => setSearchTerm(search.trim())}>
<Search className="h-4 w-4" />
Ara
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Marketplace yükleniyor...</p>}
{!isLoading && plugins.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<Store className="mb-3 h-10 w-10 text-muted-foreground/60" />
<p className="font-medium">Bu oyun için plugin bulunamadı</p>
<p className="text-sm text-muted-foreground">
Yetkili kullanıcılar yukarıdan marketplace plugin ekleyebilir.
</p>
</CardContent>
</Card>
)}
<div className="space-y-2">
{plugins.map((plugin) => (
<Card key={plugin.id}>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">{plugin.source}</Badge>
{plugin.version && <Badge variant="secondary">v{plugin.version}</Badge>}
{plugin.isInstalled && <Badge>Installed</Badge>}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
{plugin.downloadUrl && (
<p className="line-clamp-1 text-xs text-muted-foreground">{plugin.downloadUrl}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{plugin.isInstalled && plugin.installId ? (
<Button
variant="destructive"
size="sm"
onClick={() => uninstallMutation.mutate(plugin.installId!)}
disabled={uninstallMutation.isPending}
>
<Trash2 className="h-4 w-4" />
Kaldır
</Button>
) : (
<Button
size="sm"
onClick={() => installMutation.mutate(plugin.id)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Kur
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => openEditDialog(plugin)}
title="Marketplace kaydını düzenle"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => deleteMutation.mutate(plugin.id)}
disabled={deleteMutation.isPending || plugin.isInstalled}
title={plugin.isInstalled ? 'Önce sunucudan kaldırın' : 'Marketplace kaydını sil'}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function InstalledPlugins({
installed,
orgId,
@@ -111,12 +527,22 @@ function InstalledPlugins({
mutationFn: (id: string) =>
api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin durumu güncellenemedi'));
},
});
const uninstallMutation = useMutation({
mutationFn: (id: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onSuccess: () => {
toast.success('Plugin kaldırıldı');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin kaldırılamadı'));
},
});
if (installed.length === 0) {
@@ -126,7 +552,7 @@ function InstalledPlugins({
<Puzzle className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">No plugins installed</p>
<p className="mt-1 text-xs text-muted-foreground">
Search for plugins or install manually
Marketplace sekmesinden tek tıkla kurulum yapabilirsiniz.
</p>
</CardContent>
</Card>
@@ -199,7 +625,14 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
resourceId,
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onSuccess: () => {
toast.success('Spiget plugin installed');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Spiget install failed'));
},
});
const handleSearch = () => {
@@ -270,11 +703,16 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body),
onSuccess: () => {
toast.success('Plugin registered');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
setName('');
setFileName('');
setVersion('');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin registration failed'));
},
});
return (
@@ -307,7 +745,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
required
/>
<p className="text-xs text-muted-foreground">
Upload the file to /plugins/ directory via the Files tab first
Upload the file to the correct plugin directory via Files tab first.
</p>
</div>
<div className="space-y-2">