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
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:
@@ -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 "${searchQuery}".` : '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 "${browseQuery}". 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
|
||||
• 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">
|
||||
<Panel v-else :flush-body="true">
|
||||
<template #actions>
|
||||
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
|
||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
|
||||
· 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"
|
||||
>
|
||||
← 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 →
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user