821 lines
31 KiB
TypeScript
821 lines
31 KiB
TypeScript
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>
|
||
);
|
||
}
|