feat: Wire uMod browse proxy and custom plugin upload
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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:
@@ -1,5 +1,19 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
@@ -57,9 +71,38 @@ export class PluginsController {
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory' })
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
|
||||
@ApiQuery({ name: 'q', required: true, example: 'kits' })
|
||||
searchUmod(@Query('q') query: string) {
|
||||
return this.pluginsService.searchUmod(query);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
|
||||
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
|
||||
@ApiQuery({ name: 'page', required: false, example: 1 })
|
||||
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
|
||||
browseUmod(
|
||||
@Query('query') query: string,
|
||||
@Query('page') page: string,
|
||||
@Query('sort') sort: string,
|
||||
) {
|
||||
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@RequirePermission('plugin.manage')
|
||||
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadPlugin(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.pluginsService.uploadPlugin(licenseId, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||
@@ -6,9 +6,16 @@ import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
interface UmodCacheEntry {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
private readonly logger = new Logger(PluginsService.name);
|
||||
private readonly umodCache = new Map<string, UmodCacheEntry>();
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PluginRegistry)
|
||||
@@ -122,10 +129,107 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
|
||||
// uMod API integration pending
|
||||
// Legacy stub — use browseUmod via GET /plugins/browse for real results
|
||||
return {
|
||||
results: [],
|
||||
message: 'uMod search integration not yet configured',
|
||||
message: 'Use GET /plugins/browse for uMod search',
|
||||
};
|
||||
}
|
||||
|
||||
async browseUmod(query: string, page = 1, sort = 'downloads'): Promise<unknown> {
|
||||
const cacheKey = `${query ?? ''}:${page}:${sort}`;
|
||||
const cached = this.umodCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
this.logger.debug(`uMod cache hit for key "${cacheKey}"`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
sort,
|
||||
sortdir: 'desc',
|
||||
'categories[]': 'rust',
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
params.set('query', query.trim());
|
||||
}
|
||||
|
||||
const url = `https://umod.org/plugins/search.json?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'CorrosionPanel/1.0' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`uMod API returned ${response.status} for query "${query}"`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.umodCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort} → ${(data as any)?.total ?? '?'} results`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`);
|
||||
return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise<PluginRegistry> {
|
||||
// Validate extension
|
||||
const originalName = file.originalname ?? '';
|
||||
if (!originalName.toLowerCase().endsWith('.cs')) {
|
||||
throw new BadRequestException('Only .cs plugin files are accepted');
|
||||
}
|
||||
|
||||
// Validate size (5 MB)
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new BadRequestException('Plugin file exceeds the 5 MB limit');
|
||||
}
|
||||
|
||||
// Derive plugin name from filename (strip .cs)
|
||||
const pluginName = originalName.replace(/\.cs$/i, '');
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.pluginRegistryRepo.findOne({
|
||||
where: { license_id: licenseId, plugin_name: pluginName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Plugin "${pluginName}" is already installed`);
|
||||
}
|
||||
|
||||
// Persist record
|
||||
const plugin = this.pluginRegistryRepo.create({
|
||||
license_id: licenseId,
|
||||
plugin_name: pluginName,
|
||||
source: 'manual',
|
||||
is_installed: true,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
||||
const saved = await this.pluginRegistryRepo.save(plugin);
|
||||
|
||||
// Dispatch to companion agent via NATS
|
||||
try {
|
||||
const content = file.buffer.toString('base64');
|
||||
await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, {
|
||||
action: 'plugin_upload',
|
||||
filename: originalName,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`);
|
||||
// Don't fail the request — plugin record is saved, NATS delivery is best-effort
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,40 @@ export function useApi() {
|
||||
return request<T>(path, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { request, get, post, put, del }
|
||||
/**
|
||||
* Upload a FormData payload (multipart/form-data).
|
||||
* Does NOT set Content-Type — browser must set it with the correct boundary.
|
||||
*/
|
||||
async function upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth.refreshToken) {
|
||||
await attemptRefresh()
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return { request, get, post, put, del, upload }
|
||||
}
|
||||
|
||||
@@ -3,10 +3,39 @@ import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { PluginEntry } from '@/types'
|
||||
|
||||
export interface UmodPlugin {
|
||||
name: string
|
||||
title: string
|
||||
slug: string
|
||||
author: string
|
||||
description: string
|
||||
downloads: number
|
||||
downloads_shortened: string
|
||||
download_url: string
|
||||
latest_release_version: string
|
||||
latest_release_version_formatted: string
|
||||
icon_url: string
|
||||
url: string
|
||||
tags_all: string
|
||||
watchers_shortened: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UmodBrowseResult {
|
||||
current_page: number
|
||||
data: UmodPlugin[]
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const usePluginStore = defineStore('plugins', () => {
|
||||
const plugins = ref<PluginEntry[]>([])
|
||||
const searchResults = ref<any[]>([])
|
||||
const browseResults = ref<UmodBrowseResult | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isBrowseLoading = ref(false)
|
||||
const api = useApi()
|
||||
|
||||
async function fetchPlugins() {
|
||||
@@ -18,7 +47,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(data: { plugin_name: string; source: string }) {
|
||||
async function installPlugin(data: { plugin_name: string; umod_slug?: string; source: string }) {
|
||||
await api.post('/plugins/install', data)
|
||||
await fetchPlugins()
|
||||
}
|
||||
@@ -37,6 +66,7 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
await fetchPlugins()
|
||||
}
|
||||
|
||||
// Legacy — kept for backwards compatibility, routes to browse
|
||||
async function searchPlugins(query: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -46,15 +76,38 @@ export const usePluginStore = defineStore('plugins', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function browseUmod(query: string, page = 1, sort = 'downloads') {
|
||||
isBrowseLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), sort })
|
||||
if (query.trim()) params.set('query', query.trim())
|
||||
browseResults.value = await api.get<UmodBrowseResult>(`/plugins/browse?${params.toString()}`)
|
||||
} finally {
|
||||
isBrowseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPlugin(file: File): Promise<PluginEntry> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await api.upload<PluginEntry>('/plugins/upload', formData)
|
||||
await fetchPlugins()
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
searchResults,
|
||||
browseResults,
|
||||
isLoading,
|
||||
isBrowseLoading,
|
||||
fetchPlugins,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
reloadPlugin,
|
||||
updatePluginConfig,
|
||||
searchPlugins,
|
||||
browseUmod,
|
||||
uploadPlugin,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
• 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>
|
||||
|
||||
Reference in New Issue
Block a user