c1adb94abb
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.
1186 lines
40 KiB
TypeScript
1186 lines
40 KiB
TypeScript
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>
|
||
);
|
||
}
|