All checks were successful
Test Asgard Runner / test (push) Successful in 6s
Prevents KeyboardEvent being passed as page number parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
22 KiB
Vue
510 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { usePluginStore } from '@/stores/plugins'
|
|
import type { UmodPlugin } from '@/stores/plugins'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import type { PluginEntry } from '@/types'
|
|
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
|
|
|
|
const pluginStore = usePluginStore()
|
|
const toast = useToastStore()
|
|
|
|
const searchQuery = ref('')
|
|
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
|
const browseQuery = ref('')
|
|
const browsePage = ref(1)
|
|
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
const installing = ref<string | null>(null)
|
|
|
|
// Upload state
|
|
const uploadFile = ref<File | null>(null)
|
|
const isDragOver = ref(false)
|
|
const isUploading = ref(false)
|
|
const uploadInput = ref<HTMLInputElement | null>(null)
|
|
|
|
const filteredPlugins = computed(() => {
|
|
let result = pluginStore.plugins
|
|
if (searchQuery.value.trim()) {
|
|
const q = searchQuery.value.toLowerCase()
|
|
result = result.filter((p: PluginEntry) => p.plugin_name.toLowerCase().includes(q))
|
|
}
|
|
return result
|
|
})
|
|
|
|
const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
|
|
|
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
|
|
|
function sourceLabel(source: string): string {
|
|
switch (source) {
|
|
case 'umod': return 'uMod'
|
|
case 'corrosion_module': return 'Corrosion'
|
|
case 'manual': return 'Manual'
|
|
default: return source
|
|
}
|
|
}
|
|
|
|
function sourceBadgeClass(source: string): string {
|
|
switch (source) {
|
|
case 'umod': return 'bg-green-500/10 text-green-400'
|
|
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400'
|
|
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
}
|
|
}
|
|
|
|
async function handleToggleLoad(plugin: PluginEntry) {
|
|
try {
|
|
await pluginStore.reloadPlugin(plugin.id)
|
|
toast.success(`${plugin.plugin_name} ${plugin.is_loaded ? 'unloaded' : 'loaded'} successfully`)
|
|
await pluginStore.fetchPlugins()
|
|
} catch {
|
|
toast.error(`Failed to toggle ${plugin.plugin_name}`)
|
|
}
|
|
}
|
|
|
|
async function handleUninstall(plugin: PluginEntry) {
|
|
if (!confirm(`Uninstall ${plugin.plugin_name}? This cannot be undone.`)) return
|
|
try {
|
|
await pluginStore.uninstallPlugin(plugin.id)
|
|
toast.success(`${plugin.plugin_name} uninstalled`)
|
|
} catch {
|
|
toast.error(`Failed to uninstall ${plugin.plugin_name}`)
|
|
}
|
|
}
|
|
|
|
async function handleBrowseSearch(page = 1) {
|
|
if (!browseQuery.value.trim()) return
|
|
browsePage.value = page
|
|
try {
|
|
await pluginStore.browseUmod(browseQuery.value.trim(), page)
|
|
} catch {
|
|
toast.error('Failed to search uMod plugins')
|
|
}
|
|
}
|
|
|
|
function scheduleBrowseSearch() {
|
|
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
|
browseDebounce.value = setTimeout(() => handleBrowseSearch(1), 400)
|
|
}
|
|
|
|
function browsePrev() {
|
|
if (browsePage.value > 1) handleBrowseSearch(browsePage.value - 1)
|
|
}
|
|
|
|
function browseNext() {
|
|
if (pluginStore.browseResults && browsePage.value < pluginStore.browseResults.last_page) {
|
|
handleBrowseSearch(browsePage.value + 1)
|
|
}
|
|
}
|
|
|
|
async function installFromBrowse(result: UmodPlugin) {
|
|
installing.value = result.name
|
|
try {
|
|
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
|
|
toast.success(`${result.name} installed`)
|
|
} catch {
|
|
toast.error(`Failed to install ${result.name}`)
|
|
} finally {
|
|
installing.value = null
|
|
}
|
|
}
|
|
|
|
// Upload helpers
|
|
function isAlreadyInstalled(name: string): boolean {
|
|
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
|
|
}
|
|
|
|
function validateCsFile(file: File): string | null {
|
|
if (!file.name.toLowerCase().endsWith('.cs')) {
|
|
return 'Only .cs plugin files are accepted'
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
return 'File must be under 5 MB'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function handleFilePick(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
const err = validateCsFile(file)
|
|
if (err) { toast.error(err); return }
|
|
uploadFile.value = file
|
|
}
|
|
|
|
function handleDrop(event: DragEvent) {
|
|
isDragOver.value = false
|
|
const file = event.dataTransfer?.files[0]
|
|
if (!file) return
|
|
const err = validateCsFile(file)
|
|
if (err) { toast.error(err); return }
|
|
uploadFile.value = file
|
|
}
|
|
|
|
function clearUpload() {
|
|
uploadFile.value = null
|
|
if (uploadInput.value) uploadInput.value.value = ''
|
|
}
|
|
|
|
async function handleUpload() {
|
|
if (!uploadFile.value) return
|
|
isUploading.value = true
|
|
try {
|
|
await pluginStore.uploadPlugin(uploadFile.value)
|
|
toast.success(`${uploadFile.value.name} uploaded successfully`)
|
|
clearUpload()
|
|
tab.value = 'installed'
|
|
} catch (err) {
|
|
toast.error((err as Error).message || 'Upload failed')
|
|
} finally {
|
|
isUploading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
pluginStore.fetchPlugins()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6 space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<Puzzle class="w-5 h-5 text-oxide-500" />
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1>
|
|
<p class="text-sm text-neutral-500 mt-0.5">
|
|
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="pluginStore.fetchPlugins()"
|
|
:disabled="pluginStore.isLoading"
|
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs + Search -->
|
|
<div class="flex items-center gap-4">
|
|
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
<button
|
|
@click="tab = 'installed'"
|
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
>
|
|
Installed
|
|
</button>
|
|
<button
|
|
@click="tab = 'browse'"
|
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
:class="tab === 'browse' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
>
|
|
Browse uMod
|
|
</button>
|
|
<button
|
|
@click="tab = 'upload'"
|
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
>
|
|
Upload Custom
|
|
</button>
|
|
</div>
|
|
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search installed plugins..."
|
|
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
/>
|
|
</div>
|
|
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
<input
|
|
v-model="browseQuery"
|
|
type="text"
|
|
placeholder="Search uMod plugins..."
|
|
@input="scheduleBrowseSearch"
|
|
@keydown.enter="handleBrowseSearch(1)"
|
|
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Installed Plugins -->
|
|
<div v-if="tab === 'installed'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-neutral-800 text-left">
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-neutral-800">
|
|
<tr v-if="filteredPlugins.length === 0">
|
|
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
<template v-if="pluginStore.isLoading">Loading plugins...</template>
|
|
<template v-else>No plugins installed yet.</template>
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
v-for="plugin in filteredPlugins"
|
|
:key="plugin.id"
|
|
class="hover:bg-neutral-800/50 transition-colors"
|
|
>
|
|
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ plugin.plugin_name }}</td>
|
|
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td>
|
|
<td class="px-4 py-3">
|
|
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)">
|
|
{{ sourceLabel(plugin.source) }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 text-xs font-medium"
|
|
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full" :class="plugin.is_loaded ? 'bg-green-500' : 'bg-neutral-600'" />
|
|
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-neutral-500">
|
|
<template v-if="plugin.never_wipe">Never wipe</template>
|
|
<template v-else>
|
|
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
|
|
</template>
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button
|
|
@click="handleToggleLoad(plugin)"
|
|
class="p-1.5 rounded transition-colors"
|
|
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
|
|
:title="plugin.is_loaded ? 'Unload' : 'Load'"
|
|
>
|
|
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
@click="handleUninstall(plugin)"
|
|
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
|
title="Uninstall"
|
|
>
|
|
<Trash2 class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Browse uMod -->
|
|
<div v-if="tab === 'browse'">
|
|
<!-- Empty state: no search yet -->
|
|
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
|
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
|
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
|
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
|
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
|
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
|
</div>
|
|
|
|
<!-- No results -->
|
|
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
|
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
|
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
|
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
|
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
|
|
<p class="text-xs text-neutral-500">
|
|
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
|
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
|
</p>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
@click="browsePrev"
|
|
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
|
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
|
>
|
|
← Prev
|
|
</button>
|
|
<button
|
|
@click="browseNext"
|
|
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
|
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-neutral-800 text-left">
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
|
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-neutral-800">
|
|
<tr
|
|
v-for="result in browsePlugins"
|
|
:key="result.name"
|
|
class="hover:bg-neutral-800/50 transition-colors"
|
|
>
|
|
<td class="px-4 py-3">
|
|
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
|
|
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
|
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
|
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button
|
|
@click="installFromBrowse(result)"
|
|
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
|
:class="isAlreadyInstalled(result.name)
|
|
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
|
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
|
>
|
|
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
|
<Download v-else class="w-3.5 h-3.5" />
|
|
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<!-- Bottom pagination -->
|
|
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
|
|
<p class="text-xs text-neutral-500">
|
|
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
|
</p>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
@click="browsePrev"
|
|
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
|
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
|
>
|
|
← Previous
|
|
</button>
|
|
<button
|
|
@click="browseNext"
|
|
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
|
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Custom Plugin -->
|
|
<div v-if="tab === 'upload'" class="space-y-4">
|
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
|
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
|
|
<p class="text-sm text-neutral-500 mb-6">
|
|
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
|
|
The file will be pushed to your server via the companion agent.
|
|
</p>
|
|
|
|
<!-- Drop zone -->
|
|
<div
|
|
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
|
|
:class="isDragOver
|
|
? 'border-oxide-500 bg-oxide-500/5'
|
|
: uploadFile
|
|
? 'border-green-600 bg-green-900/10'
|
|
: 'border-neutral-700 hover:border-neutral-600'"
|
|
@dragover.prevent="isDragOver = true"
|
|
@dragleave.prevent="isDragOver = false"
|
|
@drop.prevent="handleDrop"
|
|
@click="uploadInput?.click()"
|
|
>
|
|
<input
|
|
ref="uploadInput"
|
|
type="file"
|
|
accept=".cs"
|
|
class="hidden"
|
|
@change="handleFilePick"
|
|
/>
|
|
|
|
<!-- No file selected -->
|
|
<template v-if="!uploadFile">
|
|
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
|
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
|
|
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
|
|
</template>
|
|
|
|
<!-- File selected -->
|
|
<template v-else>
|
|
<div class="flex items-center justify-center gap-3">
|
|
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
|
|
<div class="text-left">
|
|
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
|
|
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
|
|
</div>
|
|
<button
|
|
@click.stop="clearUpload"
|
|
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
|
title="Remove"
|
|
>
|
|
<X class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-3 mt-4">
|
|
<button
|
|
@click="handleUpload"
|
|
:disabled="!uploadFile || isUploading"
|
|
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
|
>
|
|
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
|
<Upload v-else class="w-4 h-4" />
|
|
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
|
|
</button>
|
|
<button
|
|
v-if="uploadFile"
|
|
@click="clearUpload"
|
|
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info card -->
|
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
|
<p class="text-xs text-neutral-500 leading-relaxed">
|
|
<span class="font-medium text-neutral-400">Note:</span>
|
|
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
|
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|