Add panel feature updates across API, daemon, and web
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user