Add panel feature updates across API, daemon, and web

This commit is contained in:
2026-03-02 21:53:54 +00:00
parent 6b463c2b1a
commit afc64b83c1
49 changed files with 7040 additions and 305 deletions
+820
View File
@@ -0,0 +1,820 @@
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>
);
}