Files
source-gamepanel/apps/web/src/pages/admin/plugins.tsx
T

821 lines
31 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 { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, UploadCloud, Puzzle, Rocket, Copy } 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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface Game {
id: string;
slug: string;
name: string;
}
interface GamesResponse {
data: Game[];
}
interface GlobalPlugin {
id: string;
gameId: string;
gameName: string;
gameSlug: string;
name: string;
slug: string;
description: string | null;
source: 'manual' | 'spiget';
isGlobal: boolean;
}
interface GlobalPluginsResponse {
data: GlobalPlugin[];
}
interface PluginRelease {
id: string;
pluginId: string;
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
destination: string | null;
fileName: string | null;
changelog: string | null;
installSchema: unknown[];
configTemplates: unknown[];
isPublished: boolean;
createdAt: string;
updatedAt: string;
}
interface PluginReleaseResponse {
plugin: GlobalPlugin;
releases: PluginRelease[];
}
type ReleaseInputMode = 'url' | 'upload';
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 prettyJson(input: unknown): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return '[]';
}
}
function parseJsonArray(raw: string): unknown[] {
if (raw.trim() === '') return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('JSON value must be an array');
}
return parsed;
}
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
let raw = await file.text();
if (raw.charCodeAt(0) === 0xfeff) {
raw = raw.slice(1);
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('JSON value must be an array');
}
return parsed;
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
throw new Error(`${label}: ${message}`);
}
}
export function AdminPluginsPage() {
const queryClient = useQueryClient();
const [selectedGameId, setSelectedGameId] = useState<string>('');
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
const [createPluginOpen, setCreatePluginOpen] = useState(false);
const [createPluginName, setCreatePluginName] = useState('');
const [createPluginSlug, setCreatePluginSlug] = useState('');
const [createPluginDescription, setCreatePluginDescription] = useState('');
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
const [releaseVersion, setReleaseVersion] = useState('');
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
const [releaseDestination, setReleaseDestination] = useState('');
const [releaseFileName, setReleaseFileName] = useState('');
const [releaseChangelog, setReleaseChangelog] = useState('');
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
const { data: gamesData } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<GamesResponse>('/admin/games'),
});
const games = gamesData?.data ?? [];
const { data: pluginsData } = useQuery({
queryKey: ['admin-plugins', selectedGameId],
queryFn: () =>
api.get<GlobalPluginsResponse>(
'/admin/plugins',
selectedGameId ? { gameId: selectedGameId } : undefined,
),
});
const plugins = pluginsData?.data ?? [];
const selectedPlugin = useMemo(
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
[plugins, selectedPluginId],
);
const { data: releaseData } = useQuery({
queryKey: ['admin-plugin-releases', selectedPluginId],
enabled: Boolean(selectedPluginId),
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
});
const releases = releaseData?.releases ?? [];
const resetReleaseForm = () => {
setCreateReleaseOpen(false);
setReleaseInputMode('upload');
setReleaseVersion('');
setReleaseChannel('stable');
setReleaseArtifactType('file');
setReleaseArtifactUrl('');
setReleaseDestination('');
setReleaseFileName('');
setReleaseChangelog('');
setReleaseInstallSchemaJson('[]');
setReleaseTemplatesJson('[]');
setReleaseInstallSchemaFile(null);
setReleaseTemplatesFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
setReleaseArtifactFiles([]);
};
const appendReleaseFiles = (incoming: FileList | null) => {
if (!incoming || incoming.length === 0) return;
setReleaseArtifactFiles((prev) => {
const map = new Map<string, File>();
for (const item of prev) {
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
}
for (const item of Array.from(incoming)) {
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
}
return Array.from(map.values());
});
};
const createPluginMutation = useMutation({
mutationFn: (body: {
gameId: string;
name: string;
slug?: string;
description?: string;
}) => api.post('/admin/plugins', body),
onSuccess: () => {
toast.success('Global plugin created');
setCreatePluginOpen(false);
setCreatePluginName('');
setCreatePluginSlug('');
setCreatePluginDescription('');
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create plugin'));
},
});
const createReleaseMutation = useMutation({
mutationFn: (body: {
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
}) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
},
onSuccess: () => {
toast.success('Release published');
resetReleaseForm();
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to publish release'));
},
});
const createUploadReleaseMutation = useMutation({
mutationFn: (formData: FormData) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
},
onSuccess: () => {
toast.success('Release uploaded and published');
resetReleaseForm();
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to upload release'));
},
});
const togglePublishedMutation = useMutation({
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update release'));
},
});
const openReleaseDialogFrom = (release?: PluginRelease) => {
setReleaseVersion('');
setReleaseChannel('stable');
setReleaseArtifactType('file');
setReleaseArtifactUrl('');
setReleaseDestination('');
setReleaseFileName('');
setReleaseChangelog('');
setReleaseInstallSchemaJson('[]');
setReleaseTemplatesJson('[]');
setReleaseInstallSchemaFile(null);
setReleaseTemplatesFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
setReleaseArtifactFiles([]);
setReleaseInputMode(release ? 'url' : 'upload');
if (release) {
setReleaseChannel(release.channel);
setReleaseArtifactType(release.artifactType);
setReleaseArtifactUrl(release.artifactUrl);
setReleaseDestination(release.destination ?? '');
setReleaseFileName(release.fileName ?? '');
setReleaseChangelog(release.changelog ?? '');
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
setReleaseTemplatesJson(prettyJson(release.configTemplates));
}
setCreateReleaseOpen(true);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Global Plugins</h1>
<p className="text-sm text-muted-foreground">
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
</p>
</div>
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" /> Add Global Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Global Plugin</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!selectedGameId) {
toast.error('Select a game first');
return;
}
createPluginMutation.mutate({
gameId: selectedGameId,
name: createPluginName,
slug: createPluginSlug || undefined,
description: createPluginDescription || undefined,
});
}}
>
<div className="space-y-2">
<Label>Game</Label>
<Input
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
readOnly
placeholder="Select game from filter above"
/>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={createPluginMutation.isPending}>
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-2">
<Label className="min-w-20">Game Filter</Label>
<select
className="h-10 rounded-md border bg-background px-3 text-sm"
value={selectedGameId}
onChange={(e) => {
setSelectedGameId(e.target.value);
setSelectedPluginId(null);
}}
>
<option value="">All Games</option>
{games.map((game) => (
<option key={game.id} value={game.id}>
{game.name} ({game.slug})
</option>
))}
</select>
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<Card>
<CardHeader>
<CardTitle>Plugins</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{plugins.length === 0 && (
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
)}
{plugins.map((plugin) => (
<button
key={plugin.id}
type="button"
onClick={() => setSelectedPluginId(plugin.id)}
className={`w-full rounded-md border px-3 py-2 text-left transition ${
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
}`}
>
<div className="flex items-center gap-2">
<Puzzle className="h-4 w-4 text-primary" />
<span className="font-medium">{plugin.name}</span>
<Badge variant="outline">{plugin.gameSlug}</Badge>
<Badge variant="secondary">{plugin.source}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
{plugin.description && (
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
)}
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Releases</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openReleaseDialogFrom(releases[0])}
disabled={!selectedPlugin}
>
<Copy className="h-4 w-4" /> Clone Latest
</Button>
<Button
size="sm"
onClick={() => openReleaseDialogFrom()}
disabled={!selectedPlugin}
>
<UploadCloud className="h-4 w-4" /> New Release
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2">
{!selectedPlugin && (
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
)}
{selectedPlugin && releases.length === 0 && (
<p className="text-sm text-muted-foreground">No releases published yet.</p>
)}
{releases.map((release) => (
<div key={release.id} className="rounded-md border px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">v{release.version}</span>
<Badge variant="outline">{release.channel}</Badge>
<Badge variant="secondary">{release.artifactType}</Badge>
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
</div>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
<p className="mt-1 text-xs text-muted-foreground">
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields Templates:{' '}
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
</p>
<div className="mt-2">
<Button
size="sm"
variant="outline"
onClick={() =>
togglePublishedMutation.mutate({
releaseId: release.id,
isPublished: !release.isPublished,
})
}
disabled={togglePublishedMutation.isPending}
>
<Rocket className="h-4 w-4" />
{release.isPublished ? 'Unpublish' : 'Publish'}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<Dialog
open={createReleaseOpen}
onOpenChange={(open) => {
if (open) {
setCreateReleaseOpen(true);
return;
}
resetReleaseForm();
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
try {
const installSchema = releaseInstallSchemaFile
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
: parseJsonArray(releaseInstallSchemaJson);
const configTemplates = releaseTemplatesFile
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
: parseJsonArray(releaseTemplatesJson);
if (releaseInputMode === 'upload') {
if (releaseArtifactFiles.length === 0) {
toast.error('Select at least one file or folder');
return;
}
const formData = new FormData();
formData.append('version', releaseVersion);
formData.append('channel', releaseChannel);
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
if (releaseInstallSchemaFile) {
formData.append(
'installSchemaFile',
releaseInstallSchemaFile,
releaseInstallSchemaFile.name,
);
} else {
formData.append('installSchema', JSON.stringify(installSchema));
}
if (releaseTemplatesFile) {
formData.append(
'configTemplatesFile',
releaseTemplatesFile,
releaseTemplatesFile.name,
);
} else {
formData.append('configTemplates', JSON.stringify(configTemplates));
}
for (const file of releaseArtifactFiles) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
formData.append('files', file, file.name);
}
createUploadReleaseMutation.mutate(formData);
return;
}
createReleaseMutation.mutate({
version: releaseVersion,
channel: releaseChannel,
artifactType: releaseArtifactType,
artifactUrl: releaseArtifactUrl,
destination: releaseDestination || undefined,
fileName: releaseFileName || undefined,
changelog: releaseChangelog || undefined,
installSchema,
configTemplates,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
toast.error(`Release JSON error: ${message}`);
}
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Version</Label>
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Channel</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={releaseChannel}
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
>
<option value="stable">stable</option>
<option value="beta">beta</option>
<option value="alpha">alpha</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label>Release Source</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
onClick={() => setReleaseInputMode('upload')}
>
CDN Upload
</Button>
<Button
type="button"
size="sm"
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
onClick={() => setReleaseInputMode('url')}
>
URL
</Button>
</div>
</div>
{releaseInputMode === 'url' && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Artifact Type</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={releaseArtifactType}
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
>
<option value="file">file</option>
<option value="zip">zip</option>
</select>
</div>
<div className="space-y-2">
<Label>Artifact URL</Label>
<Input
type="url"
value={releaseArtifactUrl}
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
required={releaseInputMode === 'url'}
/>
</div>
</div>
)}
{releaseInputMode === 'upload' && (
<div className="space-y-3 rounded-md border p-3">
<p className="text-xs text-muted-foreground">
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
yapilip CDN&apos;e yuklenir.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Files</Label>
<input
type="file"
multiple
className="block w-full text-sm"
onChange={(e) => appendReleaseFiles(e.target.files)}
/>
</div>
<div className="space-y-2">
<Label>Folder</Label>
<input
type="file"
multiple
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
className="block w-full text-sm"
onChange={(e) => appendReleaseFiles(e.target.files)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
{releaseArtifactFiles.length > 0 && (
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
Clear
</Button>
)}
</div>
{releaseArtifactFiles.length > 0 && (
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
{releaseArtifactFiles.map((file, index) => {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
return (
<p key={`${relativePath || file.name}-${index}`} className="truncate">
{relativePath || file.name}
</p>
);
})}
</div>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Destination (optional)</Label>
<Input
value={releaseDestination}
onChange={(e) => setReleaseDestination(e.target.value)}
placeholder="/game/csgo/addons"
/>
</div>
<div className="space-y-2">
<Label>File Name (optional)</Label>
<Input
value={releaseFileName}
onChange={(e) => setReleaseFileName(e.target.value)}
placeholder="plugin.dll"
/>
</div>
</div>
<div className="space-y-2">
<Label>Changelog (optional)</Label>
<textarea
value={releaseChangelog}
onChange={(e) => setReleaseChangelog(e.target.value)}
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Install Schema JSON (array)</Label>
<input
key={releaseInstallSchemaFileInputKey}
type="file"
accept="application/json,.json"
className="block w-full text-xs"
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
/>
{releaseInstallSchemaFile && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReleaseInstallSchemaFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
}}
>
Clear
</Button>
</div>
)}
<textarea
value={releaseInstallSchemaJson}
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>Config Templates JSON (array)</Label>
<input
key={releaseTemplatesFileInputKey}
type="file"
accept="application/json,.json"
className="block w-full text-xs"
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
/>
{releaseTemplatesFile && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReleaseTemplatesFile(null);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
}}
>
Clear
</Button>
</div>
)}
<textarea
value={releaseTemplatesJson}
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
/>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={
createReleaseMutation.isPending ||
createUploadReleaseMutation.isPending ||
!selectedPlugin
}
>
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
? 'Publishing...'
: 'Publish Release'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}