feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 02:34:46 -04:00
parent 560d023250
commit b42a2d7ea7
18 changed files with 4826 additions and 3108 deletions

View File

@@ -4,7 +4,15 @@ 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'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const pluginStore = usePluginStore()
const toast = useToastStore()
@@ -35,6 +43,12 @@ const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
const tabItems = [
{ value: 'installed', label: 'Installed' },
{ value: 'browse', label: 'Browse uMod' },
{ value: 'upload', label: 'Upload custom' },
]
function sourceLabel(source: string): string {
switch (source) {
case 'umod': return 'uMod'
@@ -44,11 +58,11 @@ function sourceLabel(source: string): string {
}
}
function sourceBadgeClass(source: string): string {
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
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'
case 'umod': return 'online'
case 'corrosion_module': return 'accent'
default: return 'neutral'
}
}
@@ -168,274 +182,249 @@ onMounted(() => {
</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 class="plv">
<!-- Page head -->
<div class="plv__head">
<div class="plv__head-id">
<div class="plv__head-chip">
<Icon name="puzzle" :size="20" :stroke-width="2" />
</div>
<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 class="t-eyebrow">Plugin management</div>
<h1 class="plv__title">Plugins</h1>
</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 class="plv__head-actions">
<div class="plv__stat-pill">
<span class="plv__stat-num">{{ loadedCount }}</span>
<span class="plv__stat-sep">/</span>
<span class="plv__stat-total">{{ pluginStore.plugins.length }}</span>
<span class="plv__stat-label">loaded</span>
</div>
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="pluginStore.isLoading"
:disabled="pluginStore.isLoading"
@click="pluginStore.fetchPlugins()"
>Refresh</Button>
</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">
<!-- Tab bar + search -->
<div class="plv__toolbar">
<Tabs v-model="tab" :items="tabItems" />
<Input
v-if="tab === 'installed'"
v-model="searchQuery"
icon="search"
placeholder="Search installed plugins…"
size="sm"
style="max-width: 280px;"
/>
<Input
v-if="tab === 'browse'"
v-model="browseQuery"
icon="search"
placeholder="Search uMod plugins…"
size="sm"
style="max-width: 280px;"
@input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)"
/>
</div>
<!-- ===== INSTALLED TAB ===== -->
<Panel v-if="tab === 'installed'" :flush-body="true">
<EmptyState
v-if="filteredPlugins.length === 0 && !pluginStore.isLoading"
icon="puzzle"
title="No plugins installed"
:description="searchQuery ? `No plugins matching &quot;${searchQuery}&quot;.` : 'Install plugins from the Browse uMod tab or upload a custom .cs file.'"
/>
<div v-else-if="pluginStore.isLoading && filteredPlugins.length === 0" class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Loading plugins</span>
</div>
<table v-else class="plv__table">
<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>
<th>Plugin</th>
<th>Version</th>
<th>Source</th>
<th>Status</th>
<th>Wipe behavior</th>
<th class="plv__th-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>
<tbody>
<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 class="plv__plugin-name">{{ plugin.plugin_name }}</td>
<td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
<td>
<Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
</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>
<Badge
:tone="plugin.is_loaded ? 'online' : 'offline'"
:dot="true"
:pulse="plugin.is_loaded"
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
</td>
<td class="px-4 py-3 text-xs text-neutral-500">
<td class="plv__secondary plv__wipe-cell">
<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
<td class="plv__td-right">
<div class="plv__row-actions">
<IconButton
:icon="plugin.is_loaded ? 'power' : 'play'"
variant="ghost"
size="sm"
:label="plugin.is_loaded ? 'Unload' : 'Load'"
@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
/>
<IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Uninstall"
@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>
</Panel>
<!-- Browse uMod -->
<!-- ===== BROWSE UMOD TAB ===== -->
<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>
<!-- No search yet -->
<Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="search"
title="Search uMod"
description="Type a plugin name above to search the uMod plugin directory."
/>
</Panel>
<!-- 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>
<Panel v-else-if="pluginStore.isBrowseLoading">
<div class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Searching uMod</span>
</div>
</Panel>
<!-- 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>
<Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="download"
title="No plugins found"
:description="`No uMod plugins matched &quot;${browseQuery}&quot;. Try a different search term.`"
/>
</Panel>
<!-- 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">
<Panel v-else :flush-body="true">
<template #actions>
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
&middot; page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
</span>
<Button
variant="ghost"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
@click="browsePrev"
>Prev</Button>
<Button
variant="ghost"
size="sm"
icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
@click="browseNext"
>Next</Button>
</template>
<table class="plv__table">
<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>
<th>Plugin</th>
<th>Author</th>
<th>Version</th>
<th>Downloads</th>
<th class="plv__th-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<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>
<div class="plv__browse-name">{{ result.title }}</div>
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
</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)"
<td class="plv__secondary">{{ result.author ?? '' }}</td>
<td class="plv__mono">{{ result.latest_release_version_formatted ?? '' }}</td>
<td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '' }}</td>
<td class="plv__td-right">
<Button
size="sm"
:variant="isAlreadyInstalled(result.name) ? 'secondary' : 'primary'"
:icon="installing === result.name ? 'loader' : 'download'"
:loading="installing === 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="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>
@click="installFromBrowse(result)"
>{{ isAlreadyInstalled(result.name) ? 'Installed' : '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">
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
<span class="plv__browse-meta">
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
<div class="flex items-center gap-1">
<button
@click="browsePrev"
</span>
<div class="plv__browse-pag">
<Button
variant="secondary"
size="sm"
icon="chevron-left"
: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"
@click="browsePrev"
>Previous</Button>
<Button
variant="secondary"
size="sm"
icon-right="chevron-right"
: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>
@click="browseNext"
>Next</Button>
</div>
</div>
</div>
</Panel>
</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>
<!-- ===== UPLOAD CUSTOM TAB ===== -->
<div v-if="tab === 'upload'" class="plv__upload-wrap">
<Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
<!-- 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'"
class="plv__dropzone"
:class="{
'plv__dropzone--active': isDragOver,
'plv__dropzone--ready': !!uploadFile && !isDragOver,
}"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop"
@@ -445,65 +434,174 @@ onMounted(() => {
ref="uploadInput"
type="file"
accept=".cs"
class="hidden"
class="plv__file-hidden"
@change="handleFilePick"
/>
<!-- No file selected -->
<!-- No file -->
<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>
<div class="plv__drop-icon">
<Icon name="upload" :size="22" :stroke-width="1.75" />
</div>
<p class="plv__drop-label">Drop your .cs file here</p>
<p class="plv__drop-hint">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 class="plv__file-row">
<div class="plv__file-icon">
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
</div>
<button
<div class="plv__file-info">
<div class="plv__file-name">{{ uploadFile.name }}</div>
<div class="plv__file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</div>
</div>
<IconButton
icon="x"
variant="ghost"
size="sm"
label="Remove"
@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"
<div class="plv__upload-actions">
<Button
icon="upload"
:loading="isUploading"
: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
@click="handleUpload"
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
<Button
v-if="uploadFile"
variant="ghost"
@click="clearUpload"
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
>Cancel</Button>
</div>
</div>
</Panel>
<!-- 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>
<Alert tone="info">
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.
</Alert>
</div>
</div>
</template>
<style scoped>
.plv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
/* Page head */
.plv__head {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.plv__head-id { display: flex; align-items: center; gap: 12px; }
.plv__head-chip {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
color: var(--accent); background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.plv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
.plv__head-actions { display: flex; align-items: center; gap: 12px; }
/* Stat pill */
.plv__stat-pill {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
}
.plv__stat-num { font-weight: 700; color: var(--accent-text); }
.plv__stat-sep { color: var(--text-muted); }
.plv__stat-total { color: var(--text-tertiary); }
.plv__stat-label { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-tertiary); }
/* Toolbar */
.plv__toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Loading */
.plv__loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
}
@keyframes plv-spin { to { transform: rotate(360deg); } }
.plv__spin { animation: plv-spin 0.7s linear infinite; }
/* Table */
.plv__table { width: 100%; border-collapse: collapse; }
.plv__table th {
padding: 10px 14px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.plv__table td {
padding: 10px 14px; font-size: var(--text-sm);
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
}
.plv__table tbody tr:last-child td { border-bottom: 0; }
.plv__table tbody tr:hover td { background: var(--surface-hover); }
.plv__th-right { text-align: right; }
.plv__td-right { text-align: right; }
.plv__plugin-name { font-weight: 500; }
.plv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
.plv__secondary { color: var(--text-secondary) !important; }
.plv__wipe-cell { font-size: var(--text-xs) !important; }
.plv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
/* Browse */
.plv__browse-meta { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__browse-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__browse-desc {
font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px;
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.plv__browse-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-top: 1px solid var(--border-subtle);
}
.plv__browse-pag { display: flex; gap: 8px; }
/* Upload */
.plv__upload-wrap { display: flex; flex-direction: column; gap: 12px; }
.plv__dropzone {
border: 2px dashed var(--border-default); border-radius: var(--radius-md);
padding: 40px 24px; text-align: center;
cursor: pointer; transition: var(--transition-colors);
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.plv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
.plv__dropzone--active { border-color: var(--accent); background: var(--accent-soft); }
.plv__dropzone--ready { border-color: var(--status-online-border); background: var(--status-online-soft); }
.plv__file-hidden { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.plv__drop-icon {
width: 46px; height: 46px; border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
background: var(--surface-raised-2); color: var(--text-tertiary);
box-shadow: var(--ring-default); margin-bottom: 4px;
}
.plv__drop-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.plv__drop-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
.plv__file-row { display: flex; align-items: center; gap: 12px; }
.plv__file-icon {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
background: var(--status-online-soft); color: var(--status-online);
box-shadow: inset 0 0 0 1px var(--status-online-border);
}
.plv__file-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__file-size { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__upload-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
</style>