feat: Wire uMod browse proxy and custom plugin upload
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

Backend:
- GET /plugins/browse proxies uMod search.json filtered to Rust category,
  with 5-minute in-memory Map cache to avoid hammering the upstream API
- POST /plugins/upload accepts .cs files up to 5 MB via multipart, persists
  to plugin_registry, and dispatches plugin_upload action over NATS so the
  companion agent can write the file to the game server
- Legacy GET /plugins/search stub preserved (now directs callers to /browse)
- FileInterceptor + @UploadedFile follow the existing maps upload pattern

Frontend:
- useApi composable gains upload() method for multipart/form-data requests
  (omits Content-Type so the browser sets the correct multipart boundary)
- plugins store adds browseUmod() calling GET /plugins/browse and
  uploadPlugin() calling POST /plugins/upload with FormData;
  UmodPlugin and UmodBrowseResult TypeScript interfaces exported
- PluginsView Browse tab now calls browseUmod() through the backend proxy
  (no cross-origin requests to uMod directly); results show title,
  downloads_shortened, and latest_release_version_formatted from the
  real uMod payload
- New Upload Custom tab: drag-and-drop or click file input for .cs files,
  client-side extension/size validation, spinner during upload, success
  toast + auto-switch to Installed tab on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 16:09:19 -05:00
parent 38e6d28248
commit 2b45413c20
5 changed files with 422 additions and 23 deletions

View File

@@ -1,19 +1,26 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
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 } from 'lucide-vue-next'
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'>('installed')
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
const browseQuery = ref('')
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()) {
@@ -23,6 +30,8 @@ const filteredPlugins = computed(() => {
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 {
@@ -65,7 +74,7 @@ async function handleUninstall(plugin: PluginEntry) {
async function handleBrowseSearch() {
if (!browseQuery.value.trim()) return
try {
await pluginStore.searchPlugins(browseQuery.value.trim())
await pluginStore.browseUmod(browseQuery.value.trim())
} catch {
toast.error('Failed to search uMod plugins')
}
@@ -76,10 +85,10 @@ function scheduleBrowseSearch() {
browseDebounce.value = setTimeout(handleBrowseSearch, 400)
}
async function installFromBrowse(result: { name: string }) {
async function installFromBrowse(result: UmodPlugin) {
installing.value = result.name
try {
await pluginStore.installPlugin({ plugin_name: result.name, source: 'umod' })
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}`)
@@ -88,6 +97,59 @@ async function installFromBrowse(result: { name: string }) {
}
}
// 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()
})
@@ -133,6 +195,13 @@ onMounted(() => {
>
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" />
@@ -230,20 +299,20 @@ onMounted(() => {
<!-- Browse uMod -->
<div v-if="tab === 'browse'">
<!-- Empty state: no search yet -->
<div v-if="!browseQuery.trim() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<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.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<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() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<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>
@@ -251,39 +320,47 @@ onMounted(() => {
<!-- 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>
<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 pluginStore.searchResults"
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.name }}</p>
<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_version ?? '\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 || pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)"
: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="pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)
: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" />
{{ pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
</button>
</td>
</tr>
@@ -291,5 +368,92 @@ onMounted(() => {
</table>
</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>