feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user