Files
corrosion-admin-panel/frontend/src/views/admin/PluginsView.vue
Vantz Stockwell f67b175d39
All checks were successful
Test Asgard Runner / test (push) Successful in 6s
fix: Pass explicit page arg to handleBrowseSearch on Enter key
Prevents KeyboardEvent being passed as page number parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:45:34 -05:00

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
&bull; 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"
>
&larr; 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 &rarr;
</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"
>
&larr; 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 &rarr;
</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>