Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
21 KiB
Vue
608 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { usePluginStore } from '@/stores/plugins'
|
|
import type { UmodPlugin } from '@/stores/plugins'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import type { PluginEntry } from '@/types'
|
|
import 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()
|
|
|
|
const searchQuery = ref('')
|
|
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
|
|
const browseQuery = ref('')
|
|
const browsePage = ref(1)
|
|
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
const installing = ref<string | null>(null)
|
|
|
|
// Upload state
|
|
const uploadFile = ref<File | null>(null)
|
|
const isDragOver = ref(false)
|
|
const isUploading = ref(false)
|
|
const uploadInput = ref<HTMLInputElement | null>(null)
|
|
|
|
const filteredPlugins = computed(() => {
|
|
let result = pluginStore.plugins
|
|
if (searchQuery.value.trim()) {
|
|
const q = searchQuery.value.toLowerCase()
|
|
result = result.filter((p: PluginEntry) => p.plugin_name.toLowerCase().includes(q))
|
|
}
|
|
return result
|
|
})
|
|
|
|
const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
|
|
|
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
|
|
|
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'
|
|
case 'corrosion_module': return 'Corrosion'
|
|
case 'manual': return 'Manual'
|
|
default: return source
|
|
}
|
|
}
|
|
|
|
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
|
|
switch (source) {
|
|
case 'umod': return 'online'
|
|
case 'corrosion_module': return 'accent'
|
|
default: return 'neutral'
|
|
}
|
|
}
|
|
|
|
async function handleToggleLoad(plugin: PluginEntry) {
|
|
try {
|
|
await pluginStore.reloadPlugin(plugin.id)
|
|
toast.success(`${plugin.plugin_name} ${plugin.is_loaded ? 'unloaded' : 'loaded'} successfully`)
|
|
await pluginStore.fetchPlugins()
|
|
} catch {
|
|
toast.error(`Failed to toggle ${plugin.plugin_name}`)
|
|
}
|
|
}
|
|
|
|
async function handleUninstall(plugin: PluginEntry) {
|
|
if (!confirm(`Uninstall ${plugin.plugin_name}? This cannot be undone.`)) return
|
|
try {
|
|
await pluginStore.uninstallPlugin(plugin.id)
|
|
toast.success(`${plugin.plugin_name} uninstalled`)
|
|
} catch {
|
|
toast.error(`Failed to uninstall ${plugin.plugin_name}`)
|
|
}
|
|
}
|
|
|
|
async function handleBrowseSearch(page = 1) {
|
|
if (!browseQuery.value.trim()) return
|
|
browsePage.value = page
|
|
try {
|
|
await pluginStore.browseUmod(browseQuery.value.trim(), page)
|
|
} catch {
|
|
toast.error('Failed to search uMod plugins')
|
|
}
|
|
}
|
|
|
|
function scheduleBrowseSearch() {
|
|
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
|
browseDebounce.value = setTimeout(() => handleBrowseSearch(1), 400)
|
|
}
|
|
|
|
function browsePrev() {
|
|
if (browsePage.value > 1) handleBrowseSearch(browsePage.value - 1)
|
|
}
|
|
|
|
function browseNext() {
|
|
if (pluginStore.browseResults && browsePage.value < pluginStore.browseResults.last_page) {
|
|
handleBrowseSearch(browsePage.value + 1)
|
|
}
|
|
}
|
|
|
|
async function installFromBrowse(result: UmodPlugin) {
|
|
installing.value = result.name
|
|
try {
|
|
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
|
|
toast.success(`${result.name} installed`)
|
|
} catch {
|
|
toast.error(`Failed to install ${result.name}`)
|
|
} finally {
|
|
installing.value = null
|
|
}
|
|
}
|
|
|
|
// Upload helpers
|
|
function isAlreadyInstalled(name: string): boolean {
|
|
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
|
|
}
|
|
|
|
function validateCsFile(file: File): string | null {
|
|
if (!file.name.toLowerCase().endsWith('.cs')) {
|
|
return 'Only .cs plugin files are accepted'
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
return 'File must be under 5 MB'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function handleFilePick(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
const err = validateCsFile(file)
|
|
if (err) { toast.error(err); return }
|
|
uploadFile.value = file
|
|
}
|
|
|
|
function handleDrop(event: DragEvent) {
|
|
isDragOver.value = false
|
|
const file = event.dataTransfer?.files[0]
|
|
if (!file) return
|
|
const err = validateCsFile(file)
|
|
if (err) { toast.error(err); return }
|
|
uploadFile.value = file
|
|
}
|
|
|
|
function clearUpload() {
|
|
uploadFile.value = null
|
|
if (uploadInput.value) uploadInput.value.value = ''
|
|
}
|
|
|
|
async function handleUpload() {
|
|
if (!uploadFile.value) return
|
|
isUploading.value = true
|
|
try {
|
|
await pluginStore.uploadPlugin(uploadFile.value)
|
|
toast.success(`${uploadFile.value.name} uploaded successfully`)
|
|
clearUpload()
|
|
tab.value = 'installed'
|
|
} catch (err) {
|
|
toast.error((err as Error).message || 'Upload failed')
|
|
} finally {
|
|
isUploading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
pluginStore.fetchPlugins()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="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>
|
|
<div class="t-eyebrow">Plugin management</div>
|
|
<h1 class="plv__title">Plugins</h1>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
<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>
|
|
<tr
|
|
v-for="plugin in filteredPlugins"
|
|
:key="plugin.id"
|
|
>
|
|
<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>
|
|
<Badge
|
|
:tone="plugin.is_loaded ? 'online' : 'offline'"
|
|
:dot="true"
|
|
:pulse="plugin.is_loaded"
|
|
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
|
|
</td>
|
|
<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="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)"
|
|
/>
|
|
<IconButton
|
|
icon="trash-2"
|
|
variant="danger"
|
|
size="sm"
|
|
label="Uninstall"
|
|
@click="handleUninstall(plugin)"
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Panel>
|
|
|
|
<!-- ===== BROWSE UMOD TAB ===== -->
|
|
<div v-if="tab === 'browse'">
|
|
<!-- 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 -->
|
|
<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 -->
|
|
<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 -->
|
|
<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>
|
|
<th>Plugin</th>
|
|
<th>Author</th>
|
|
<th>Version</th>
|
|
<th>Downloads</th>
|
|
<th class="plv__th-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="result in browsePlugins"
|
|
:key="result.name"
|
|
>
|
|
<td>
|
|
<div class="plv__browse-name">{{ result.title }}</div>
|
|
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
|
|
</td>
|
|
<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)"
|
|
@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="plv__browse-foot">
|
|
<span class="plv__browse-meta">
|
|
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
|
</span>
|
|
<div class="plv__browse-pag">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
icon="chevron-left"
|
|
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
|
@click="browsePrev"
|
|
>Previous</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
icon-right="chevron-right"
|
|
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
|
@click="browseNext"
|
|
>Next</Button>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
<!-- ===== 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="plv__dropzone"
|
|
:class="{
|
|
'plv__dropzone--active': isDragOver,
|
|
'plv__dropzone--ready': !!uploadFile && !isDragOver,
|
|
}"
|
|
@dragover.prevent="isDragOver = true"
|
|
@dragleave.prevent="isDragOver = false"
|
|
@drop.prevent="handleDrop"
|
|
@click="uploadInput?.click()"
|
|
>
|
|
<input
|
|
ref="uploadInput"
|
|
type="file"
|
|
accept=".cs"
|
|
class="plv__file-hidden"
|
|
@change="handleFilePick"
|
|
/>
|
|
|
|
<!-- No file -->
|
|
<template v-if="!uploadFile">
|
|
<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="plv__file-row">
|
|
<div class="plv__file-icon">
|
|
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
|
|
</div>
|
|
<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"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="plv__upload-actions">
|
|
<Button
|
|
icon="upload"
|
|
:loading="isUploading"
|
|
:disabled="!uploadFile || isUploading"
|
|
@click="handleUpload"
|
|
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
|
|
<Button
|
|
v-if="uploadFile"
|
|
variant="ghost"
|
|
@click="clearUpload"
|
|
>Cancel</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Alert tone="info">
|
|
The plugin will be registered in your plugin list immediately. Your host 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>
|