Files
source-gamepanel/apps/web/src/pages/server/plugins.tsx
T
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
2026-08-02 21:08:12 +03:00

1186 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useOutletContext, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Download,
Puzzle,
Search,
Star,
ToggleLeft,
ToggleRight,
Trash2,
Upload,
Store,
Plus,
Pencil,
} from 'lucide-react';
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, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface PluginInstallField {
key: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select';
description?: string;
required?: boolean;
defaultValue?: unknown;
options?: Array<{ label: string; value: string }>;
min?: number;
max?: number;
pattern?: string;
secret?: boolean;
}
interface InstalledPluginRelease {
id: string;
version: string;
installSchema: PluginInstallField[];
}
interface InstalledPlugin {
id: string;
pluginId: string;
releaseId: string | null;
name: string;
slug: string;
description: string | null;
source: 'spiget' | 'manual';
externalId: string | null;
installedVersion: string | null;
isActive: boolean;
installOptions: Record<string, unknown>;
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
isPinned: boolean;
status: 'installed' | 'updating' | 'failed';
lastError: string | null;
updateAvailable: boolean;
latestReleaseId: string | null;
latestVersion: string | null;
latestChannel: 'stable' | 'beta' | 'alpha' | null;
installedAt: string;
currentRelease: InstalledPluginRelease | null;
}
interface SpigetResult {
id: number;
name: string;
tag: string;
downloads: number;
rating: { average: number; count: number };
updateDate: number;
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;
isPinned: boolean;
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
installedAt: string | null;
releaseId: string | null;
updateAvailable: boolean;
latestRelease: {
id: string;
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
installSchema: PluginInstallField[];
} | 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;
}
function buildInstallOptionsState(
fields: PluginInstallField[],
current: Record<string, unknown> = {},
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const field of fields) {
if (field.defaultValue !== undefined) {
next[field.key] = field.defaultValue;
}
}
for (const [key, value] of Object.entries(current)) {
next[key] = value;
}
return next;
}
function PluginInstallSchemaFields({
fields,
values,
onChange,
}: {
fields: PluginInstallField[];
values: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
}) {
return (
<>
{fields.map((field) => (
<div className="space-y-2" key={field.key}>
<Label>{field.label}</Label>
{field.type === 'select' ? (
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={String(values[field.key] ?? '')}
onChange={(e) => onChange({ ...values, [field.key]: e.target.value })}
>
<option value="">Select...</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : field.type === 'boolean' ? (
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(values[field.key])}
onChange={(e) => onChange({ ...values, [field.key]: e.target.checked })}
/>
Enabled
</label>
) : (
<Input
type={field.type === 'number' ? 'number' : field.secret ? 'password' : 'text'}
value={String(values[field.key] ?? '')}
onChange={(e) =>
onChange({
...values,
[field.key]:
field.type === 'number'
? e.target.value === ''
? ''
: Number(e.target.value)
: e.target.value,
})
}
min={field.type === 'number' ? field.min : undefined}
max={field.type === 'number' ? field.max : undefined}
pattern={field.type === 'text' ? field.pattern : undefined}
required={Boolean(field.required)}
/>
)}
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</>
);
}
export function PluginsPage() {
const { orgId, serverId } = useParams();
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
const isMinecraft = server?.gameSlug === 'minecraft-java';
const { data: pluginsData } = useQuery({
queryKey: ['plugins', orgId, serverId],
queryFn: () =>
api.get<{ plugins: InstalledPlugin[] }>(
`/organizations/${orgId}/servers/${serverId}/plugins`,
),
});
const installed = pluginsData?.plugins ?? [];
return (
<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>
{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>
{isMinecraft && (
<TabsContent value="search">
<SpigetSearch orgId={orgId!} serverId={serverId!} />
</TabsContent>
)}
<TabsContent value="manual">
<ManualInstall orgId={orgId!} serverId={serverId!} />
</TabsContent>
</Tabs>
);
}
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 [installDialogOpen, setInstallDialogOpen] = useState(false);
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
const [installPinVersion, setInstallPinVersion] = useState(false);
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<
'stable' | 'beta' | 'alpha'
>('stable');
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,
payload,
}: {
pluginId: string;
payload?: {
releaseId?: string;
options?: Record<string, unknown>;
pinVersion?: boolean;
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
};
}) =>
api.post(
`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`,
payload ?? {},
),
onSuccess: () => {
toast.success('Plugin installed');
setInstallDialogOpen(false);
setInstallTarget(null);
setInstallOptions({});
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 updateInstallMutation = useMutation({
mutationFn: ({ installId }: { installId: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}/update`),
onSuccess: () => {
toast.success('Plugin updated');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin update 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 openInstallDialog = (plugin: MarketplacePlugin) => {
const fields = plugin.latestRelease?.installSchema ?? [];
setInstallTarget(plugin);
setInstallOptions(buildInstallOptionsState(fields));
setInstallPinVersion(false);
setInstallAutoUpdateChannel('stable');
setInstallDialogOpen(true);
};
const installDirect = (plugin: MarketplacePlugin) => {
installMutation.mutate({
pluginId: plugin.id,
payload: {
releaseId: plugin.latestRelease?.id ?? undefined,
pinVersion: false,
autoUpdateChannel: 'stable',
},
});
};
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.latestRelease?.version && (
<Badge variant="secondary">v{plugin.latestRelease.version}</Badge>
)}
{plugin.isInstalled && <Badge>Installed</Badge>}
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
{plugin.latestRelease?.artifactUrl && (
<p className="line-clamp-1 text-xs text-muted-foreground">
{plugin.latestRelease.artifactUrl}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{plugin.isInstalled && plugin.installId ? (
<>
{plugin.updateAvailable && (
<Button
size="sm"
variant="secondary"
onClick={() =>
updateInstallMutation.mutate({ installId: plugin.installId! })
}
disabled={updateInstallMutation.isPending}
>
<Download className="h-4 w-4" />
Güncelle
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => uninstallMutation.mutate(plugin.installId!)}
disabled={uninstallMutation.isPending}
>
<Trash2 className="h-4 w-4" />
Kaldır
</Button>
</>
) : (
<>
{(plugin.latestRelease?.installSchema?.length ?? 0) > 0 ? (
<Button
size="sm"
onClick={() => openInstallDialog(plugin)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Ayarla ve Kur
</Button>
) : (
<Button
size="sm"
onClick={() => installDirect(plugin)}
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>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Install Plugin
{installTarget ? ` - ${installTarget.name}` : ''}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!installTarget) return;
installMutation.mutate({
pluginId: installTarget.id,
payload: {
releaseId: installTarget.latestRelease?.id ?? undefined,
options: installOptions,
pinVersion: installPinVersion,
autoUpdateChannel: installAutoUpdateChannel,
},
});
}}
>
<PluginInstallSchemaFields
fields={installTarget?.latestRelease?.installSchema ?? []}
values={installOptions}
onChange={setInstallOptions}
/>
<div className="space-y-2">
<Label>Auto Update Channel</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={installAutoUpdateChannel}
onChange={(e) =>
setInstallAutoUpdateChannel(e.target.value as 'stable' | 'beta' | 'alpha')
}
>
<option value="stable">stable</option>
<option value="beta">beta</option>
<option value="alpha">alpha</option>
</select>
</div>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={installPinVersion}
onChange={(e) => setInstallPinVersion(e.target.checked)}
/>
Pin this release version
</label>
<DialogFooter>
<Button type="submit" disabled={installMutation.isPending || !installTarget}>
{installMutation.isPending ? 'Installing...' : 'Install'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
function InstalledPlugins({
installed,
orgId,
serverId,
}: {
installed: InstalledPlugin[];
orgId: string;
serverId: string;
}) {
const queryClient = useQueryClient();
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
const [configureTarget, setConfigureTarget] = useState<InstalledPlugin | null>(null);
const [configureOptions, setConfigureOptions] = useState<Record<string, unknown>>({});
const toggleMutation = useMutation({
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: () => {
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ı'));
},
});
const updateMutation = useMutation({
mutationFn: (id: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`),
onSuccess: () => {
toast.success('Plugin güncellendi');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin güncellenemedi'));
},
});
const configureMutation = useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: {
releaseId: string;
options: Record<string, unknown>;
};
}) => api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`, payload),
onSuccess: () => {
toast.success('Plugin ayarları güncellendi');
setConfigureDialogOpen(false);
setConfigureTarget(null);
setConfigureOptions({});
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin ayarları güncellenemedi'));
},
});
const openConfigureDialog = (plugin: InstalledPlugin) => {
const fields = plugin.currentRelease?.installSchema ?? [];
setConfigureTarget(plugin);
setConfigureOptions(buildInstallOptionsState(fields, plugin.installOptions));
setConfigureDialogOpen(true);
};
if (installed.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<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">
Marketplace sekmesinden tek tıkla kurulum yapabilirsiniz.
</p>
</CardContent>
</Card>
);
}
return (
<>
<div className="space-y-2">
{installed.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="flex items-start gap-3">
<Puzzle className="h-5 w-5 text-primary" />
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">{plugin.source}</Badge>
{plugin.installedVersion && (
<Badge variant="secondary">v{plugin.installedVersion}</Badge>
)}
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
{plugin.status !== 'installed' && (
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
{plugin.status}
</Badge>
)}
{plugin.updateAvailable && (
<Badge variant="destructive">Update Available</Badge>
)}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
{plugin.latestVersion && (
<p className="text-xs text-muted-foreground">
Latest: v{plugin.latestVersion}
{plugin.latestChannel ? ` (${plugin.latestChannel})` : ''}
</p>
)}
{plugin.lastError && (
<p className="text-xs text-destructive">{plugin.lastError}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{plugin.currentRelease && plugin.currentRelease.installSchema.length > 0 && (
<Button
size="icon"
variant="ghost"
onClick={() => openConfigureDialog(plugin)}
title="Ayarları düzenle"
disabled={configureMutation.isPending}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{plugin.updateAvailable && (
<Button
size="icon"
variant="ghost"
onClick={() => updateMutation.mutate(plugin.id)}
title="Update"
disabled={updateMutation.isPending}
>
<Download className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => toggleMutation.mutate(plugin.id)}
title={plugin.isActive ? 'Disable' : 'Enable'}
>
{plugin.isActive ? (
<ToggleRight className="h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
)}
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => uninstallMutation.mutate(plugin.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<Dialog
open={configureDialogOpen}
onOpenChange={(open) => {
setConfigureDialogOpen(open);
if (!open) {
setConfigureTarget(null);
setConfigureOptions({});
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Plugin Ayarları
{configureTarget ? ` - ${configureTarget.name}` : ''}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!configureTarget?.currentRelease) return;
configureMutation.mutate({
id: configureTarget.id,
payload: {
releaseId: configureTarget.currentRelease.id,
options: configureOptions,
},
});
}}
>
<PluginInstallSchemaFields
fields={configureTarget?.currentRelease?.installSchema ?? []}
values={configureOptions}
onChange={setConfigureOptions}
/>
<DialogFooter>
<Button
type="submit"
disabled={configureMutation.isPending || !configureTarget?.currentRelease}
>
{configureMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string }) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const { data: results, isLoading } = useQuery({
queryKey: ['spiget-search', orgId, serverId, searchTerm],
queryFn: () =>
api.get<{ results: SpigetResult[] }>(
`/organizations/${orgId}/servers/${serverId}/plugins/search`,
{ q: searchTerm },
),
enabled: searchTerm.length >= 2,
});
const installMutation = useMutation({
mutationFn: (resourceId: number) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
resourceId,
}),
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 = () => {
if (query.length >= 2) setSearchTerm(query);
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Search Spiget plugins (Minecraft only)..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch} disabled={query.length < 2}>
<Search className="h-4 w-4" />
Search
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Searching...</p>}
{results?.results && results.results.length === 0 && (
<p className="text-sm text-muted-foreground">No results found</p>
)}
<div className="space-y-2">
{results?.results?.map((r) => (
<Card key={r.id}>
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-muted-foreground">{r.tag}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Star className="h-3 w-3" />
{r.rating.average.toFixed(1)} ({r.rating.count})
</span>
<span>
<Download className="inline h-3 w-3" /> {r.downloads.toLocaleString()}
</span>
</div>
</div>
<Button
size="sm"
onClick={() => installMutation.mutate(r.id)}
disabled={installMutation.isPending || r.external}
>
<Download className="h-4 w-4" />
{r.external ? 'External' : 'Install'}
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState('');
const [version, setVersion] = useState('');
const installMutation = useMutation({
mutationFn: (body: { name: string; filePath: 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('');
setFilePath('');
setVersion('');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin registration failed'));
},
});
return (
<Card>
<CardHeader>
<CardTitle>Manual Plugin Install</CardTitle>
</CardHeader>
<CardContent>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
installMutation.mutate({
name,
filePath,
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>File Path</Label>
<Input
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="plugins/plugin.jar"
required
/>
<p className="text-xs text-muted-foreground">
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun
varsayılan plugin dizinine göre çözülür.
</p>
</div>
<div className="space-y-2">
<Label>Version (optional)</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<Button type="submit" disabled={installMutation.isPending}>
{installMutation.isPending ? 'Registering...' : 'Register Plugin'}
</Button>
</form>
</CardContent>
</Card>
);
}