feat(redesign): re-skin admin-ops/platform-admin/public views to DS (Phase D batch 4 — panel re-skin complete)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
|
||||
const hasError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
@@ -20,18 +21,67 @@ function retry() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasError" class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
||||
<AlertTriangle class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Something went wrong</h1>
|
||||
<p class="text-sm text-neutral-400 mb-6">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div v-if="hasError" class="eb-screen">
|
||||
<div class="eb-card">
|
||||
<div class="eb-icon-wrap">
|
||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||
</div>
|
||||
<h1 class="eb-title">Something went wrong</h1>
|
||||
<p class="eb-msg">{{ errorMessage }}</p>
|
||||
<Button icon="refresh-cw" @click="retry">Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.eb-screen {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.eb-card {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default), var(--shadow-md);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eb-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--status-offline-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||
color: var(--status-offline);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.eb-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.eb-msg {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.55;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950">
|
||||
<div class="pub-shell">
|
||||
<RouterView />
|
||||
|
||||
<footer class="py-6 text-center text-neutral-600 text-sm border-t border-neutral-800">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
||||
</div>
|
||||
<footer class="pub-footer">
|
||||
<Logo :size="18" :wordmark="true" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pub-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pub-footer {
|
||||
margin-top: auto;
|
||||
padding: var(--space-6) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
warps: Record<string, { x: number; y: number; z: number }>
|
||||
@@ -28,49 +30,139 @@ function removeWarp(name: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
||||
<div class="warp-editor">
|
||||
<div class="warp-editor__label">Warps</div>
|
||||
|
||||
<!-- Add Warp -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<!-- Add warp row -->
|
||||
<div class="warp-editor__add">
|
||||
<Input
|
||||
v-model="newWarpName"
|
||||
placeholder="Warp name..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
:mono="true"
|
||||
style="flex: 1"
|
||||
@keydown.enter="addWarp"
|
||||
/>
|
||||
<button
|
||||
@click="addWarp"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
:disabled="!newWarpName.trim()"
|
||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
@click="addWarp"
|
||||
>Add</Button>
|
||||
</div>
|
||||
|
||||
<!-- Warp List -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
||||
<!-- Empty state -->
|
||||
<div v-if="Object.keys(warps).length === 0" class="warp-editor__empty">
|
||||
No warps defined. Add warps here and set coordinates in-game.
|
||||
</div>
|
||||
|
||||
<!-- Warp list -->
|
||||
<div
|
||||
v-for="(coords, name) in warps"
|
||||
:key="name"
|
||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
||||
class="warp-row"
|
||||
>
|
||||
<div>
|
||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-3">
|
||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
||||
<div class="warp-row__id">
|
||||
<span class="warp-row__name">{{ name }}</span>
|
||||
<span class="warp-row__coords">
|
||||
{{ (coords as { x: number; y: number; z: number }).x.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).y.toFixed(1) }},
|
||||
{{ (coords as { x: number; y: number; z: number }).z.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="warp-row__remove"
|
||||
type="button"
|
||||
:aria-label="`Remove warp ${name}`"
|
||||
@click="removeWarp(name as string)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<Icon name="trash-2" :size="14" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warp-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.warp-editor__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warp-editor__add {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.warp-editor__empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.warp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.warp-row__id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warp-row__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warp-row__coords {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.warp-row__remove {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.warp-row__remove:hover {
|
||||
background: var(--status-offline-soft);
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.warp-row__remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { FileText, Tag, Loader2 } 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 EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string
|
||||
@@ -38,13 +42,13 @@ function loadMore() {
|
||||
fetchChangelog()
|
||||
}
|
||||
|
||||
function getCategoryColor(category: string): string {
|
||||
function categoryTone(category: string): 'online' | 'offline' | 'info' | 'warn' | 'neutral' {
|
||||
switch (category) {
|
||||
case 'feature': return 'bg-green-500/10 text-green-400'
|
||||
case 'bugfix': return 'bg-red-500/10 text-red-400'
|
||||
case 'module': return 'bg-blue-500/10 text-blue-400'
|
||||
case 'security': return 'bg-yellow-500/10 text-yellow-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
case 'feature': return 'online'
|
||||
case 'bugfix': return 'offline'
|
||||
case 'module': return 'info'
|
||||
case 'security': return 'warn'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,60 +58,127 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Changelog</h1>
|
||||
<div class="cl">
|
||||
<!-- Page head -->
|
||||
<div class="cl__head">
|
||||
<div class="cl__head-id">
|
||||
<div class="cl__head-chip">
|
||||
<Icon name="file-text" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform</div>
|
||||
<h1 class="cl__title">Changelog</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changelog Feed -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
<!-- Loading state — first load -->
|
||||
<div v-if="isLoading && entries.length === 0" class="cl__loading">
|
||||
<Icon name="loader" :size="22" class="cl__spin" />
|
||||
<span>Loading changelog…</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<Panel v-else-if="!isLoading && entries.length === 0" title="Changelog">
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="No entries yet"
|
||||
description="Platform changelog entries will appear here."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Entry feed -->
|
||||
<template v-else>
|
||||
<Panel
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
||||
:title="entry.title"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-lg">
|
||||
<Tag class="w-3 h-3 text-oxide-400" />
|
||||
<span class="text-xs font-mono text-oxide-400">{{ entry.version }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
||||
:class="getCategoryColor(entry.category)"
|
||||
>
|
||||
{{ entry.category }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-neutral-100 mb-2">{{ entry.title }}</h3>
|
||||
<div class="text-sm text-neutral-300 whitespace-pre-line leading-relaxed">
|
||||
{{ entry.body }}
|
||||
</div>
|
||||
<template #actions>
|
||||
<Badge tone="accent" :mono="true">{{ entry.version }}</Badge>
|
||||
<Badge :tone="categoryTone(entry.category)">{{ entry.category }}</Badge>
|
||||
<span class="cl__date">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||
</template>
|
||||
<div class="cl__body">{{ entry.body }}</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Load more spinner -->
|
||||
<div v-if="isLoading" class="cl__loading">
|
||||
<Icon name="loader" :size="20" class="cl__spin" />
|
||||
<span>Loading more…</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center py-6">
|
||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
||||
<!-- Load more button -->
|
||||
<div v-else-if="hasMore" class="cl__more">
|
||||
<Button variant="secondary" @click="loadMore">Load more</Button>
|
||||
</div>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-else-if="hasMore" class="flex justify-center">
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- End of List -->
|
||||
<div v-else class="text-center py-6 text-sm text-neutral-500">
|
||||
No more changelog entries
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of list -->
|
||||
<div v-else class="cl__end">No more changelog entries</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.cl__head { display: flex; align-items: center; }
|
||||
.cl__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.cl__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);
|
||||
}
|
||||
.cl__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Entry body */
|
||||
.cl__body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-line;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Date label in actions slot */
|
||||
.cl__date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Loading / footer states */
|
||||
.cl__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 28px 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@keyframes cl-spin { to { transform: rotate(360deg); } }
|
||||
.cl__spin { animation: cl-spin 0.7s linear infinite; }
|
||||
|
||||
.cl__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.cl__end {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import ConsoleLine from '@/components/ds/data/ConsoleLine.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 Input from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const server = useServerStore()
|
||||
const ws = useWebSocket()
|
||||
|
||||
interface ConsoleLine {
|
||||
interface LogLine {
|
||||
timestamp: string
|
||||
text: string
|
||||
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
||||
}
|
||||
|
||||
const lines = ref<ConsoleLine[]>([])
|
||||
const lines = ref<LogLine[]>([])
|
||||
const commandInput = ref('')
|
||||
const consoleEl = ref<HTMLElement | null>(null)
|
||||
const sending = ref(false)
|
||||
@@ -22,7 +27,7 @@ function now(): string {
|
||||
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||
}
|
||||
|
||||
function addLine(text: string, type: ConsoleLine['type'] = 'info') {
|
||||
function addLine(text: string, type: LogLine['type'] = 'info') {
|
||||
lines.value.push({ timestamp: now(), text, type })
|
||||
scrollToBottom()
|
||||
}
|
||||
@@ -60,13 +65,12 @@ function clearConsole() {
|
||||
lines.value = []
|
||||
}
|
||||
|
||||
function lineColor(type: ConsoleLine['type']): string {
|
||||
function lineLevel(type: LogLine['type']): 'cmd' | 'warn' | 'error' | 'info' {
|
||||
switch (type) {
|
||||
case 'command': return 'text-oxide-400'
|
||||
case 'warning': return 'text-yellow-400'
|
||||
case 'error': return 'text-red-400'
|
||||
case 'system': return 'text-neutral-500'
|
||||
default: return 'text-neutral-300'
|
||||
case 'command': return 'cmd'
|
||||
case 'warning': return 'warn'
|
||||
case 'error': return 'error'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +87,10 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
addLine('Corrosion Console initialized.', 'system')
|
||||
addLine('Corrosion console initialized.', 'system')
|
||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||
if (server.connection?.connection_status !== 'connected') {
|
||||
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
|
||||
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||
}
|
||||
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
||||
})
|
||||
@@ -100,70 +104,127 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Terminal class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Server Console</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="server.connection?.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-neutral-400">
|
||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
<div class="cv">
|
||||
<!-- Page head -->
|
||||
<div class="cv__head">
|
||||
<div class="cv__head-id">
|
||||
<div class="cv__head-chip">
|
||||
<Icon name="terminal" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<button
|
||||
@click="clearConsole"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
<div>
|
||||
<div class="t-eyebrow">Server management</div>
|
||||
<h1 class="cv__title">Console</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv__head-actions">
|
||||
<Badge
|
||||
:tone="server.connection?.connection_status === 'connected' ? 'online' : 'offline'"
|
||||
:dot="true"
|
||||
:pulse="server.connection?.connection_status === 'connected'"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Clear
|
||||
</button>
|
||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" icon="trash-2" @click="clearConsole">Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console output -->
|
||||
<div
|
||||
ref="consoleEl"
|
||||
class="flex-1 bg-black/80 border border-neutral-800 rounded-t-lg p-4 overflow-y-auto font-mono text-sm leading-relaxed min-h-0"
|
||||
>
|
||||
<div v-if="lines.length === 0" class="text-neutral-600 italic">
|
||||
No output yet. Send a command to get started.
|
||||
<!-- Console panel -->
|
||||
<Panel :flush-body="true" title="Output">
|
||||
<div ref="consoleEl" class="cv__output">
|
||||
<div v-if="lines.length === 0" class="cv__empty">
|
||||
No output yet. Send a command to get started.
|
||||
</div>
|
||||
<ConsoleLine
|
||||
v-for="(line, i) in lines"
|
||||
:key="i"
|
||||
:time="line.timestamp"
|
||||
:level="lineLevel(line.type)"
|
||||
>{{ line.text }}</ConsoleLine>
|
||||
</div>
|
||||
<div
|
||||
v-for="(line, i) in lines"
|
||||
:key="i"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<span class="text-neutral-600 shrink-0 select-none">{{ line.timestamp }}</span>
|
||||
<span :class="lineColor(line.type)" class="whitespace-pre-wrap break-all">{{ line.text }}</span>
|
||||
<div class="cv__bar">
|
||||
<span class="cv__bar-prompt">$</span>
|
||||
<Input
|
||||
v-model="commandInput"
|
||||
:mono="true"
|
||||
size="sm"
|
||||
placeholder="say Hello everyone…"
|
||||
:disabled="sending"
|
||||
style="flex: 1"
|
||||
@keydown.enter="handleSend"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="corner-down-left"
|
||||
:loading="sending"
|
||||
:disabled="!commandInput.trim() || sending"
|
||||
@click="handleSend"
|
||||
>Send</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command input -->
|
||||
<div class="flex bg-neutral-900 border border-t-0 border-neutral-800 rounded-b-lg overflow-hidden">
|
||||
<span class="flex items-center px-3 text-oxide-500 font-mono text-sm select-none">$</span>
|
||||
<input
|
||||
v-model="commandInput"
|
||||
@keydown.enter="handleSend"
|
||||
type="text"
|
||||
placeholder="say Hello everyone..."
|
||||
:disabled="sending"
|
||||
class="flex-1 bg-transparent py-3 text-neutral-100 placeholder-neutral-600 font-mono text-sm focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
@click="handleSend"
|
||||
:disabled="!commandInput.trim() || sending"
|
||||
class="flex items-center gap-2 px-4 text-sm font-medium text-oxide-400 hover:text-oxide-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send class="w-4 h-4" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cv {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.cv__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.cv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.cv__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);
|
||||
}
|
||||
.cv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.cv__head-actions { display: flex; align-items: center; gap: 9px; }
|
||||
|
||||
/* Output area */
|
||||
.cv__output {
|
||||
min-height: 420px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cv__empty {
|
||||
padding: 16px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Command bar */
|
||||
.cv__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
.cv__bar-prompt {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-text);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -26,18 +27,22 @@ const finderConfig = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
||||
<div class="fm">
|
||||
<!-- Page head -->
|
||||
<div class="fm__head">
|
||||
<div class="fm__head-id">
|
||||
<div class="fm__head-chip">
|
||||
<Icon name="folder-open" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Server management</div>
|
||||
<h1 class="fm__title">File manager</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
||||
style="min-height: 640px;"
|
||||
>
|
||||
<!-- VueFinder wrapper — only the outer chrome is re-skinned; internals untouched -->
|
||||
<div class="fm__finder">
|
||||
<VueFinder
|
||||
id="corrosion-filemanager"
|
||||
:driver="driver"
|
||||
@@ -47,3 +52,36 @@ const finderConfig = {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.fm__head { display: flex; align-items: center; gap: 12px; }
|
||||
.fm__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.fm__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);
|
||||
}
|
||||
.fm__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Finder container — surface panel chrome, VueFinder renders inside */
|
||||
.fm__finder {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
min-height: 640px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
|
||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||
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 Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface ExportRecord {
|
||||
id: string
|
||||
@@ -20,6 +25,8 @@ const isExporting = ref(false)
|
||||
const isImporting = ref(false)
|
||||
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const importError = ref<string | null>(null)
|
||||
const importSuccess = ref(false)
|
||||
|
||||
async function fetchExports() {
|
||||
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
||||
@@ -49,6 +56,8 @@ async function importData() {
|
||||
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
||||
|
||||
isImporting.value = true
|
||||
importError.value = null
|
||||
importSuccess.value = false
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
@@ -63,18 +72,20 @@ async function importData() {
|
||||
throw new Error('Import failed')
|
||||
}
|
||||
|
||||
alert('Import successful')
|
||||
importSuccess.value = true
|
||||
uploadFile.value = null
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Import failed')
|
||||
importError.value = err instanceof Error ? err.message : 'Import failed'
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return safeFileSize(bytes)
|
||||
}
|
||||
const EXPORT_TYPE_OPTIONS = [
|
||||
{ value: 'full' as const, label: 'Full' },
|
||||
{ value: 'config_only' as const, label: 'Config only' },
|
||||
{ value: 'store_only' as const, label: 'Store only' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchExports()
|
||||
@@ -82,110 +93,251 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Migration</h1>
|
||||
<div class="mv">
|
||||
<!-- Page head -->
|
||||
<div class="mv__head">
|
||||
<div class="mv__head-id">
|
||||
<div class="mv__head-chip">
|
||||
<Icon name="upload" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform</div>
|
||||
<h1 class="mv__title">Migration</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Export Data</h2>
|
||||
<div class="flex items-end gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-2">Export Type</label>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<!-- Export section -->
|
||||
<Panel title="Export data" subtitle="Create a portable backup of your configuration">
|
||||
<div class="mv__export-row">
|
||||
<div class="mv__export-field">
|
||||
<div class="mv__field-label">Export type</div>
|
||||
<div class="mv__seg">
|
||||
<button
|
||||
v-for="opt in (['full', 'config_only', 'store_only'] as const)"
|
||||
:key="opt"
|
||||
@click="exportType = opt"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
||||
:class="exportType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt.replace('_', ' ') }}
|
||||
</button>
|
||||
v-for="opt in EXPORT_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="mv__seg-btn"
|
||||
:class="exportType === opt.value && 'mv__seg-btn--active'"
|
||||
@click="exportType = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="createExport"
|
||||
<Button
|
||||
icon="download"
|
||||
:loading="isExporting"
|
||||
:disabled="isExporting"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isExporting" class="w-4 h-4 animate-spin" />
|
||||
<Download v-else class="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
@click="createExport"
|
||||
>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Export History -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="p-5 border-b border-neutral-800">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Export History</h2>
|
||||
</div>
|
||||
<div v-if="exports.length === 0" class="p-8 text-center text-neutral-500">
|
||||
No exports yet.
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
||||
<!-- Export history -->
|
||||
<Panel :flush-body="true" title="Export history">
|
||||
<EmptyState
|
||||
v-if="exports.length === 0"
|
||||
icon="file-text"
|
||||
title="No exports yet"
|
||||
description="Create an export above to see it listed here."
|
||||
/>
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Created</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Size</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<tbody>
|
||||
<tr v-for="exp in exports" :key="exp.id">
|
||||
<td>
|
||||
<Badge tone="neutral">{{ exp.export_type.replace('_', ' ') }}</Badge>
|
||||
</td>
|
||||
<td class="td-mono">{{ safeDate(exp.created_at) }}</td>
|
||||
<td class="td-mono">{{ safeFileSize(exp.file_size_bytes) }}</td>
|
||||
<td>
|
||||
<a
|
||||
v-if="exp.download_url"
|
||||
:href="exp.download_url"
|
||||
class="text-oxide-400 hover:text-oxide-300 text-sm transition-colors"
|
||||
class="mv__dl-link"
|
||||
>
|
||||
<Icon name="download" :size="13" />
|
||||
Download
|
||||
</a>
|
||||
<span v-else class="text-sm text-neutral-600">Preparing...</span>
|
||||
<span v-else class="mv__preparing">Preparing…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Import Section -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Import Data</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="border-2 border-dashed border-neutral-700 rounded-lg p-6 text-center">
|
||||
<Upload class="w-8 h-8 text-neutral-500 mx-auto mb-2" />
|
||||
<!-- Import section -->
|
||||
<Panel title="Import data" subtitle="Restore from a JSON or ZIP export file">
|
||||
<div class="mv__import-body">
|
||||
<Alert v-if="importSuccess" tone="online">Import completed successfully.</Alert>
|
||||
<Alert v-if="importError" tone="danger">{{ importError }}</Alert>
|
||||
|
||||
<label for="file-upload" class="mv__dropzone">
|
||||
<Icon name="upload" :size="28" class="mv__dropzone-icon" />
|
||||
<span class="mv__dropzone-label">
|
||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||
</span>
|
||||
<span class="mv__dropzone-hint">JSON or ZIP exports</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".json,.zip"
|
||||
class="mv__file-input"
|
||||
@change="handleFileSelect"
|
||||
class="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label for="file-upload" class="cursor-pointer">
|
||||
<span class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-neutral-500 mt-1">JSON or ZIP exports</p>
|
||||
</div>
|
||||
<button
|
||||
@click="importData"
|
||||
</label>
|
||||
|
||||
<Button
|
||||
:block="true"
|
||||
icon="upload"
|
||||
:loading="isImporting"
|
||||
:disabled="!uploadFile || isImporting"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
@click="importData"
|
||||
>Import</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mv {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.mv__head { display: flex; align-items: center; }
|
||||
.mv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.mv__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);
|
||||
}
|
||||
.mv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Export row */
|
||||
.mv__export-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mv__export-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.mv__field-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Segment control (mirrors WipesView pattern) */
|
||||
.mv__seg {
|
||||
display: flex;
|
||||
background: var(--surface-inset);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mv__seg-btn {
|
||||
height: var(--control-h-md);
|
||||
padding: 0 14px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.mv__seg-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||
.mv__seg-btn--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Download link */
|
||||
.mv__dl-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--accent-text);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.mv__dl-link:hover { color: var(--accent); }
|
||||
.mv__preparing { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
|
||||
/* Import body */
|
||||
.mv__import-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
.mv__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 32px 24px;
|
||||
border: 1.5px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
position: relative;
|
||||
}
|
||||
.mv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||
.mv__dropzone-icon { color: var(--text-tertiary); }
|
||||
.mv__dropzone:hover .mv__dropzone-icon { color: var(--accent-text); }
|
||||
.mv__dropzone-label { font-size: var(--text-sm); font-weight: 500; color: var(--accent-text); }
|
||||
.mv__dropzone-hint { font-size: var(--text-xs); color: var(--text-muted); }
|
||||
.mv__file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { Calendar, ChevronLeft, ChevronRight } 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 EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
|
||||
@@ -53,6 +57,8 @@ function nextMonth() {
|
||||
currentMonth.value = d
|
||||
}
|
||||
|
||||
const activeSchedules = computed(() => wipeStore.schedules.filter(s => s.is_active))
|
||||
|
||||
onMounted(() => {
|
||||
wipeStore.fetchHistory()
|
||||
wipeStore.fetchSchedules()
|
||||
@@ -60,79 +66,177 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Calendar class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Calendar</h1>
|
||||
<div class="wc">
|
||||
<!-- Page head -->
|
||||
<div class="wc__head">
|
||||
<div class="wc__head-id">
|
||||
<div class="wc__head-chip">
|
||||
<Icon name="calendar" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Auto-wiper</div>
|
||||
<h1 class="wc__title">Wipe calendar</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<button @click="prevMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
||||
<ChevronLeft class="w-5 h-5" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-neutral-200">{{ monthLabel }}</h2>
|
||||
<button @click="nextMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
||||
<ChevronRight class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Calendar panel -->
|
||||
<Panel :flush-body="true" title="Wipe calendar">
|
||||
<template #actions>
|
||||
<Button variant="ghost" size="sm" icon="chevron-left" @click="prevMonth" />
|
||||
<span class="wc__month-label">{{ monthLabel }}</span>
|
||||
<Button variant="ghost" size="sm" icon="chevron-right" @click="nextMonth" />
|
||||
</template>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<!-- Day headers -->
|
||||
<div class="grid grid-cols-7 border-b border-neutral-800">
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="wc__dow-row">
|
||||
<div
|
||||
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="day"
|
||||
class="py-2 text-center text-xs font-medium text-neutral-500 uppercase"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
class="wc__dow"
|
||||
>{{ day }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Day cells -->
|
||||
<div class="grid grid-cols-7">
|
||||
<div class="wc__grid">
|
||||
<div
|
||||
v-for="(day, i) in calendarDays"
|
||||
:key="i"
|
||||
class="min-h-20 p-2 border-b border-r border-neutral-800 last:border-r-0"
|
||||
:class="{ 'bg-neutral-800/30': !day.inMonth }"
|
||||
class="wc__cell"
|
||||
:class="{ 'wc__cell--out': !day.inMonth }"
|
||||
>
|
||||
<template v-if="day.inMonth">
|
||||
<p class="text-sm" :class="day.hasWipe ? 'text-oxide-400 font-bold' : 'text-neutral-400'">
|
||||
{{ day.date }}
|
||||
</p>
|
||||
<div v-if="day.hasWipe" class="mt-1">
|
||||
<span class="text-xs bg-oxide-500/15 text-oxide-400 px-1.5 py-0.5 rounded">
|
||||
{{ day.wipeType }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="wc__date" :class="{ 'wc__date--wipe': day.hasWipe }">{{ day.date }}</span>
|
||||
<Badge
|
||||
v-if="day.hasWipe"
|
||||
tone="warn"
|
||||
size="md"
|
||||
class="wc__wipe-badge"
|
||||
>{{ day.wipeType }}</Badge>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Upcoming schedules -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Active Schedules</h2>
|
||||
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 text-center py-4">
|
||||
No active schedules.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<!-- Active schedules panel -->
|
||||
<Panel title="Active schedules" subtitle="Cron schedules currently enabled">
|
||||
<EmptyState
|
||||
v-if="activeSchedules.length === 0"
|
||||
icon="calendar-clock"
|
||||
title="No active schedules"
|
||||
description="Activate a schedule in the auto-wiper to see it here."
|
||||
/>
|
||||
<div v-else class="wc__sched-list">
|
||||
<div
|
||||
v-for="schedule in wipeStore.schedules.filter(s => s.is_active)"
|
||||
v-for="schedule in activeSchedules"
|
||||
:key="schedule.id"
|
||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
||||
class="wc__sched-row"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">{{ schedule.schedule_name }}</p>
|
||||
<p class="text-xs text-neutral-500 font-mono">{{ schedule.cron_expression }}</p>
|
||||
<div class="wc__sched-info">
|
||||
<div class="wc__sched-name">{{ schedule.schedule_name }}</div>
|
||||
<div class="wc__sched-meta">
|
||||
<span class="wc__mono">{{ schedule.cron_expression }}</span>
|
||||
· {{ schedule.timezone }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wc__sched-next">
|
||||
Next:
|
||||
<span class="wc__mono">
|
||||
{{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-400">
|
||||
Next: {{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wc {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.wc__head { display: flex; align-items: center; }
|
||||
.wc__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.wc__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);
|
||||
}
|
||||
.wc__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.wc__month-label {
|
||||
font-size: var(--text-sm); font-weight: 600; color: var(--text-primary);
|
||||
padding: 0 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.wc__dow-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.wc__dow {
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
|
||||
.wc__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
.wc__cell {
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wc__cell:nth-child(7n) { border-right: 0; }
|
||||
.wc__cell--out { background: var(--surface-inset); }
|
||||
|
||||
.wc__date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wc__date--wipe { color: var(--accent-text); font-weight: 700; }
|
||||
.wc__wipe-badge { align-self: flex-start; }
|
||||
|
||||
/* Active schedules list */
|
||||
.wc__sched-list { display: flex; flex-direction: column; }
|
||||
.wc__sched-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.wc__sched-row:last-child { border-bottom: 0; }
|
||||
.wc__sched-info { flex: 1; min-width: 0; }
|
||||
.wc__sched-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.wc__sched-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||
.wc__sched-next { font-size: var(--text-xs); color: var(--text-tertiary); flex: none; }
|
||||
.wc__mono { font-family: var(--font-mono); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wc__cell { min-height: 52px; padding: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { History, RefreshCw } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
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 EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'success': return 'bg-green-500/10 text-green-400'
|
||||
case 'failed':
|
||||
case 'rolled_back': return 'bg-red-500/10 text-red-400'
|
||||
case 'wiping':
|
||||
case 'pre_wipe':
|
||||
case 'post_wipe': return 'bg-yellow-500/10 text-yellow-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
}
|
||||
function wipeTone(status: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||
if (status === 'success') return 'online'
|
||||
if (status === 'failed' || status === 'rolled_back') return 'offline'
|
||||
if (status === 'wiping' || status === 'pre_wipe' || status === 'post_wipe') return 'warn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function duration(start: string | null, end: string | null): string {
|
||||
if (!start || !end) return '\u2014'
|
||||
if (!start || !end) return '—'
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
const s = Math.floor(ms / 1000)
|
||||
const m = Math.floor(s / 60)
|
||||
@@ -33,68 +32,154 @@ 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">
|
||||
<History class="w-5 h-5 text-oxide-500" />
|
||||
<div class="wh">
|
||||
<!-- Page head -->
|
||||
<div class="wh__head">
|
||||
<div class="wh__head-id">
|
||||
<div class="wh__head-chip">
|
||||
<Icon name="clock" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe History</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">{{ wipeStore.history.length }} wipes recorded</p>
|
||||
<div class="t-eyebrow">Auto-wiper</div>
|
||||
<h1 class="wh__title">Wipe history</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="wipeStore.fetchHistory()"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="wipeStore.isLoading"
|
||||
:disabled="wipeStore.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': wipeStore.isLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
@click="wipeStore.fetchHistory()"
|
||||
>Refresh</Button>
|
||||
</div>
|
||||
|
||||
<!-- Summary line -->
|
||||
<div v-if="!wipeStore.isLoading" class="wh__summary">
|
||||
{{ wipeStore.history.length }} wipe{{ wipeStore.history.length === 1 ? '' : 's' }} recorded
|
||||
</div>
|
||||
|
||||
<!-- History table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" title="All wipes">
|
||||
<EmptyState
|
||||
v-if="wipeStore.history.length === 0 && !wipeStore.isLoading"
|
||||
icon="trash-2"
|
||||
title="No wipe history"
|
||||
description="Wipes will appear here once they run."
|
||||
/>
|
||||
|
||||
<div v-else-if="wipeStore.isLoading" class="wh__loading">
|
||||
<Icon name="loader" :size="20" class="wh__spin" />
|
||||
<span>Loading history…</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="cc-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">Type</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Trigger</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">Started</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Map</th>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Trigger</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Map</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="wipeStore.history.length === 0">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="wipeStore.isLoading">Loading history...</template>
|
||||
<template v-else>No wipe history yet.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="wipe in wipeStore.history"
|
||||
:key="wipe.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100 capitalize">{{ wipe.wipe_type }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="statusBadgeClass(wipe.status)">
|
||||
{{ wipe.status.replace('_', ' ') }}
|
||||
</span>
|
||||
<td class="td-primary">{{ wipe.wipe_type }}</td>
|
||||
<td>{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
||||
<td>
|
||||
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status.replace('_', ' ') }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||
{{ safeDate(wipe.started_at, '\u2014') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
{{ duration(wipe.started_at, wipe.completed_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ wipe.map_used || '\u2014' }}</td>
|
||||
<td class="td-mono">{{ safeDate(wipe.started_at, '—') }}</td>
|
||||
<td class="td-mono">{{ duration(wipe.started_at, wipe.completed_at) }}</td>
|
||||
<td>{{ wipe.map_used || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wh {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.wh__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.wh__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.wh__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);
|
||||
}
|
||||
.wh__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.wh__summary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.wh__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 36px 16px;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@keyframes wh-spin { to { transform: rotate(360deg); } }
|
||||
.wh__spin { animation: wh-spin 0.7s linear infinite; }
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-primary { color: var(--text-primary); font-weight: 500; text-transform: capitalize; }
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Key, KeyRound, Users, DollarSign, Server, UserPlus, ArrowRight, ScrollText, CreditCard, MonitorCog } from 'lucide-vue-next'
|
||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
@@ -21,26 +23,26 @@ const stats = ref<PlatformStats | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const kpiCards = [
|
||||
{ key: 'total_licenses' as const, label: 'Total Licenses', icon: Key, format: 'number' },
|
||||
{ key: 'active_licenses' as const, label: 'Active Licenses', icon: KeyRound, format: 'number' },
|
||||
{ key: 'total_users' as const, label: 'Total Users', icon: Users, format: 'number' },
|
||||
{ key: 'module_mrr' as const, label: 'Module MRR', icon: DollarSign, format: 'currency' },
|
||||
{ key: 'servers_online' as const, label: 'Servers Online', icon: Server, format: 'number' },
|
||||
{ key: 'new_signups_this_week' as const, label: 'New Signups This Week', icon: UserPlus, format: 'number' },
|
||||
{ key: 'total_licenses' as const, label: 'Total licenses', icon: 'key', format: 'number' },
|
||||
{ key: 'active_licenses' as const, label: 'Active licenses', icon: 'key', format: 'number' },
|
||||
{ key: 'total_users' as const, label: 'Total users', icon: 'users', format: 'number' },
|
||||
{ key: 'module_mrr' as const, label: 'Module MRR', icon: 'dollar-sign', format: 'currency' },
|
||||
{ key: 'servers_online' as const, label: 'Servers online', icon: 'server', format: 'number' },
|
||||
{ key: 'new_signups_this_week' as const, label: 'New signups this week', icon: 'users', format: 'number' },
|
||||
]
|
||||
|
||||
const quickLinks = [
|
||||
{ label: 'Licenses', description: 'Manage license keys and activations', icon: Key, route: '/admin/licenses' },
|
||||
{ label: 'Subscriptions', description: 'View module subscriptions and MRR', icon: CreditCard, route: '/admin/subscriptions' },
|
||||
{ label: 'Users', description: 'Manage platform users and permissions', icon: Users, route: '/admin/users' },
|
||||
{ label: 'Servers', description: 'Monitor connected game servers', icon: MonitorCog, route: '/admin/servers' },
|
||||
{ label: 'Licenses', description: 'Manage license keys and activations', icon: 'key', route: '/admin/licenses' },
|
||||
{ label: 'Subscriptions', description: 'View module subscriptions and MRR', icon: 'credit-card', route: '/admin/subscriptions' },
|
||||
{ label: 'Users', description: 'Manage platform users and permissions', icon: 'users', route: '/admin/users' },
|
||||
{ label: 'Servers', description: 'Monitor connected game servers', icon: 'server-cog', route: '/admin/servers' },
|
||||
]
|
||||
|
||||
function formatValue(value: number | undefined, format: string): string {
|
||||
if (format === 'currency') {
|
||||
return safeCurrency(value, '$', '\u2014')
|
||||
return safeCurrency(value, '$', '—')
|
||||
}
|
||||
return safeLocaleString(value, '\u2014')
|
||||
return safeLocaleString(value, '—')
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
@@ -60,58 +62,91 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-8 bg-neutral-950 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<ScrollText class="w-6 h-6 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Platform Admin</h1>
|
||||
<div class="pa-dash">
|
||||
<!-- Page head -->
|
||||
<div class="pa-dash__head">
|
||||
<div class="pa-dash__head-id">
|
||||
<div class="pa-dash__chip">
|
||||
<Icon name="layout-dashboard" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform admin</div>
|
||||
<h1 class="pa-dash__title">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400 ml-9">Overview of all platform activity and key metrics.</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
<!-- KPI row -->
|
||||
<div class="pa-dash__kpis">
|
||||
<StatCard
|
||||
v-for="card in kpiCards"
|
||||
:key="card.key"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
||||
<component :is="card.icon" class="w-4 h-4 text-oxide-400" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400">{{ card.label }}</p>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="h-8 w-24 bg-neutral-800 rounded animate-pulse" />
|
||||
<!-- Value -->
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">
|
||||
{{ stats ? formatValue(stats[card.key], card.format) : '\u2014' }}
|
||||
</p>
|
||||
</div>
|
||||
:icon="card.icon"
|
||||
:label="card.label"
|
||||
:value="isLoading ? '—' : (stats ? formatValue(stats[card.key], card.format) : '—')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-200 mb-4">Quick Links</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Quick links -->
|
||||
<Panel title="Quick links">
|
||||
<div class="pa-dash__links">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.route"
|
||||
class="pa-dash__link"
|
||||
@click="router.push(link.route)"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 text-left hover:border-oxide-500/40 hover:bg-neutral-800/50 transition-all group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
||||
<component :is="link.icon" class="w-4 h-4 text-oxide-400" />
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-neutral-600 group-hover:text-oxide-400 transition-colors" />
|
||||
<div class="pa-dash__link-icon">
|
||||
<Icon :name="link.icon" :size="16" :stroke-width="2" />
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-neutral-100 mt-3">{{ link.label }}</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">{{ link.description }}</p>
|
||||
<div class="pa-dash__link-body">
|
||||
<div class="pa-dash__link-label">{{ link.label }}</div>
|
||||
<div class="pa-dash__link-desc">{{ link.description }}</div>
|
||||
</div>
|
||||
<Icon name="chevron-right" :size="15" class="pa-dash__link-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pa-dash { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.pa-dash__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.pa-dash__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pa-dash__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);
|
||||
}
|
||||
.pa-dash__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
.pa-dash__kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; }
|
||||
|
||||
.pa-dash__links { display: flex; flex-direction: column; gap: 2px; }
|
||||
.pa-dash__link {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 8px;
|
||||
border-radius: var(--radius-md); background: transparent; border: 0;
|
||||
cursor: pointer; text-align: left; transition: var(--transition-colors);
|
||||
width: 100%;
|
||||
}
|
||||
.pa-dash__link:hover { background: var(--surface-hover); }
|
||||
.pa-dash__link-icon {
|
||||
width: 32px; height: 32px; flex: none; border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent-text); background: var(--accent-soft);
|
||||
}
|
||||
.pa-dash__link-body { flex: 1; min-width: 0; }
|
||||
.pa-dash__link-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||
.pa-dash__link-desc { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||
.pa-dash__link-arrow { color: var(--text-muted); flex: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pa-dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Key, Search, Download, Plus, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -60,11 +67,11 @@ const statusOptions = [
|
||||
{ value: 'revoked', label: 'Revoked' },
|
||||
]
|
||||
|
||||
const statusBadgeClass: Record<string, string> = {
|
||||
active: 'bg-green-500/10 text-green-400',
|
||||
suspended: 'bg-yellow-500/10 text-yellow-400',
|
||||
expired: 'bg-neutral-700/50 text-neutral-400',
|
||||
revoked: 'bg-red-500/10 text-red-400',
|
||||
const statusTone: Record<string, 'online' | 'warn' | 'neutral' | 'offline'> = {
|
||||
active: 'online',
|
||||
suspended: 'warn',
|
||||
expired: 'neutral',
|
||||
revoked: 'offline',
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
@@ -175,214 +182,234 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Key class="w-5 h-5 text-oxide-500" />
|
||||
<div class="pal">
|
||||
<!-- Page head -->
|
||||
<div class="pal__head">
|
||||
<div class="pal__head-id">
|
||||
<div class="pal__chip">
|
||||
<Icon name="key" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">License Management</h1>
|
||||
<p class="text-sm text-neutral-400 mt-0.5">{{ total }} licenses total</p>
|
||||
<div class="t-eyebrow">Platform admin</div>
|
||||
<h1 class="pal__title">License management</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="exportCsv"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
@click="showGenerateForm = !showGenerateForm"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Generate License
|
||||
</button>
|
||||
<div class="pal__head-actions">
|
||||
<Button variant="secondary" icon="download" @click="exportCsv">Export CSV</Button>
|
||||
<Button icon="plus" @click="showGenerateForm = !showGenerateForm">Generate license</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate License Form -->
|
||||
<div
|
||||
v-if="showGenerateForm"
|
||||
class="bg-neutral-900 border border-oxide-500/30 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-200">Generate New License</h3>
|
||||
<button @click="showGenerateForm = false" class="text-neutral-500 hover:text-neutral-300">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
<!-- Generate license form -->
|
||||
<Panel v-if="showGenerateForm" title="Generate new license">
|
||||
<template #actions>
|
||||
<IconButton icon="x" label="Close" @click="showGenerateForm = false" />
|
||||
</template>
|
||||
<div class="pal__gen-form">
|
||||
<Input
|
||||
v-model="generateEmail"
|
||||
type="email"
|
||||
placeholder="Owner email address..."
|
||||
class="flex-1 px-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"
|
||||
placeholder="Owner email address"
|
||||
icon="mail"
|
||||
style="flex: 1"
|
||||
@keydown.enter="generateLicense"
|
||||
/>
|
||||
<button
|
||||
@click="generateLicense"
|
||||
<Button
|
||||
icon="key"
|
||||
:loading="isGenerating"
|
||||
:disabled="isGenerating || !generateEmail.trim()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
{{ isGenerating ? 'Generating...' : 'Generate' }}
|
||||
</button>
|
||||
@click="generateLicense"
|
||||
>{{ isGenerating ? 'Generating...' : 'Generate' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div 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 by key, email, or server name..."
|
||||
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>
|
||||
<select
|
||||
<div class="pal__filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by key, email, or server name"
|
||||
icon="search"
|
||||
style="flex: 1; max-width: 360px"
|
||||
/>
|
||||
<Select
|
||||
v-model="statusFilter"
|
||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" :subtitle="total + ' licenses total'">
|
||||
<table class="pal__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">License Key</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Owner Email</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Server Name</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">Created</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Expires</th>
|
||||
<tr class="pal__thead-row">
|
||||
<th class="pal__th">License key</th>
|
||||
<th class="pal__th">Owner email</th>
|
||||
<th class="pal__th">Server name</th>
|
||||
<th class="pal__th">Status</th>
|
||||
<th class="pal__th">Created</th>
|
||||
<th class="pal__th">Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="licenses.length === 0 && !isLoading">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="searchQuery || statusFilter !== 'all'">No licenses matching your filters.</template>
|
||||
<template v-else>No licenses found.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading licenses...</td>
|
||||
<td colspan="6" class="pal__td-empty">Loading licenses...</td>
|
||||
</tr>
|
||||
<tr v-else-if="licenses.length === 0">
|
||||
<td colspan="6" class="pal__td-es">
|
||||
<EmptyState
|
||||
icon="key"
|
||||
:title="(searchQuery || statusFilter !== 'all') ? 'No matching licenses' : 'No licenses found'"
|
||||
:description="(searchQuery || statusFilter !== 'all') ? 'Try adjusting your search or filter.' : 'Generate a license to get started.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="license in licenses"
|
||||
:key="license.id"
|
||||
class="pal__tr"
|
||||
:class="{ 'pal__tr--selected': selectedLicenseId === license.id }"
|
||||
@click="selectLicense(license.id)"
|
||||
class="hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||
:class="{ 'bg-neutral-800/30': selectedLicenseId === license.id }"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-neutral-100 font-mono">{{ license.license_key }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ license.owner_email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ license.server_name }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
||||
:class="statusBadgeClass[license.status] || 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ license.status }}
|
||||
</span>
|
||||
<td class="pal__td pal__td--mono">{{ license.license_key }}</td>
|
||||
<td class="pal__td pal__td--secondary">{{ license.owner_email }}</td>
|
||||
<td class="pal__td pal__td--secondary">{{ license.server_name ?? '—' }}</td>
|
||||
<td class="pal__td">
|
||||
<Badge :tone="statusTone[license.status] ?? 'neutral'">{{ license.status }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(license.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(license.expires_at) }}</td>
|
||||
<td class="pal__td pal__td--secondary">{{ formatDate(license.created_at) }}</td>
|
||||
<td class="pal__td pal__td--secondary">{{ formatDate(license.expires_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div
|
||||
v-if="selectedLicenseId"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
||||
>
|
||||
<div v-if="isDetailLoading" class="space-y-3">
|
||||
<div class="h-5 w-40 bg-neutral-800 rounded animate-pulse" />
|
||||
<div class="h-4 w-64 bg-neutral-800 rounded animate-pulse" />
|
||||
<div class="h-4 w-48 bg-neutral-800 rounded animate-pulse" />
|
||||
<!-- Detail panel -->
|
||||
<Panel v-if="selectedLicenseId" title="License details">
|
||||
<template #actions>
|
||||
<IconButton icon="x" label="Close" @click="selectedLicenseId = null; selectedDetail = null" />
|
||||
</template>
|
||||
<div v-if="isDetailLoading" class="pal__detail-skeleton">
|
||||
<div class="pal__skel" />
|
||||
<div class="pal__skel pal__skel--wide" />
|
||||
<div class="pal__skel" />
|
||||
</div>
|
||||
<div v-else-if="selectedDetail">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-neutral-100">License Details</h3>
|
||||
<button @click="selectedLicenseId = null; selectedDetail = null" class="text-neutral-500 hover:text-neutral-300">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-neutral-500">License Key</span>
|
||||
<p class="text-neutral-200 mt-0.5 font-mono text-xs">{{ selectedDetail.license_key }}</p>
|
||||
<div class="pal__detail-grid">
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">License key</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.license_key }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Owner</span>
|
||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.owner_email }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Owner</div>
|
||||
<div class="pal__field-val">{{ selectedDetail.owner_email }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Team Members</span>
|
||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.team_count }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Team members</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.team_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Wipe Count</span>
|
||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.wipe_count }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Wipe count</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.wipe_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedDetail.server_connection" class="mt-4 pt-4 border-t border-neutral-800">
|
||||
<h4 class="text-sm font-medium text-neutral-300 mb-3">Server Connection</h4>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-neutral-500">Connection Type</span>
|
||||
<p class="text-neutral-200 mt-0.5 capitalize">{{ selectedDetail.server_connection.connection_type }}</p>
|
||||
<div v-if="selectedDetail.server_connection" class="pal__conn">
|
||||
<div class="pal__conn-head">Server connection</div>
|
||||
<div class="pal__detail-grid">
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Connection type</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.connection_type }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Server IP</span>
|
||||
<p class="text-neutral-200 mt-0.5 font-mono">{{ selectedDetail.server_connection.server_ip }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Server IP</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.server_ip }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Game Port</span>
|
||||
<p class="text-neutral-200 mt-0.5 font-mono">{{ selectedDetail.server_connection.game_port }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Game port</div>
|
||||
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.game_port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-500">Status</span>
|
||||
<p class="text-neutral-200 mt-0.5 capitalize">{{ selectedDetail.server_connection.status }}</p>
|
||||
<div class="pal__field">
|
||||
<div class="pal__field-label">Status</div>
|
||||
<div class="pal__field-val">{{ selectedDetail.server_connection.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-neutral-500">
|
||||
Page {{ page }} of {{ totalPages }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="page <= 1"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="page >= totalPages"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="pal__pager">
|
||||
<span class="pal__pager-info">Page {{ page }} of {{ totalPages }}</span>
|
||||
<div class="pal__pager-btns">
|
||||
<Button variant="secondary" size="sm" icon="chevron-left" :disabled="page <= 1" @click="prevPage">Prev</Button>
|
||||
<Button variant="secondary" size="sm" icon-right="chevron-right" :disabled="page >= totalPages" @click="nextPage">Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pal { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.pal__head { display: flex; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; gap: 12px; row-gap: 10px; }
|
||||
.pal__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pal__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);
|
||||
}
|
||||
.pal__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.pal__head-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Generate form */
|
||||
.pal__gen-form { display: flex; align-items: flex-end; gap: 10px; }
|
||||
|
||||
/* Filters */
|
||||
.pal__filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Table */
|
||||
.pal__table { width: 100%; border-collapse: collapse; }
|
||||
.pal__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pal__th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pal__tr { border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: var(--transition-colors); }
|
||||
.pal__tr:last-child { border-bottom: 0; }
|
||||
.pal__tr:hover { background: var(--surface-hover); }
|
||||
.pal__tr--selected { background: var(--accent-soft); }
|
||||
.pal__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pal__td--secondary { color: var(--text-secondary); }
|
||||
.pal__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); }
|
||||
.pal__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.pal__td-es { padding: 0; }
|
||||
|
||||
/* Detail panel */
|
||||
.pal__detail-skeleton { display: flex; flex-direction: column; gap: 10px; }
|
||||
.pal__skel { height: 16px; width: 160px; background: var(--surface-raised-2); border-radius: var(--radius-sm); animation: pal-pulse 1.4s ease-in-out infinite; }
|
||||
.pal__skel--wide { width: 260px; }
|
||||
@keyframes pal-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.pal__detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||
.pal__field-label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
||||
.pal__field-val { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.pal__field-val--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); }
|
||||
|
||||
.pal__conn { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
.pal__conn-head { font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 14px; }
|
||||
|
||||
/* Pagination */
|
||||
.pal__pager { display: flex; align-items: center; justify-content: space-between; }
|
||||
.pal__pager-info { font-size: var(--text-sm); color: var(--text-tertiary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.pal__pager-btns { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pal__detail-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Server, Search } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -29,24 +35,18 @@ const statusOptions = [
|
||||
{ value: 'offline', label: 'Offline' },
|
||||
]
|
||||
|
||||
const connectionBadgeClass: Record<string, string> = {
|
||||
plugin: 'bg-blue-500/10 text-blue-400',
|
||||
companion: 'bg-purple-500/10 text-purple-400',
|
||||
amp: 'bg-cyan-500/10 text-cyan-400',
|
||||
pterodactyl: 'bg-green-500/10 text-green-400',
|
||||
bare_metal: 'bg-orange-500/10 text-orange-400',
|
||||
const connTypeTone: Record<string, 'info' | 'accent' | 'neutral' | 'online' | 'warn'> = {
|
||||
plugin: 'info',
|
||||
companion: 'accent',
|
||||
amp: 'neutral',
|
||||
pterodactyl: 'online',
|
||||
bare_metal: 'warn',
|
||||
}
|
||||
|
||||
const statusDotClass: Record<string, string> = {
|
||||
connected: 'bg-green-500',
|
||||
degraded: 'bg-yellow-500',
|
||||
offline: 'bg-red-500',
|
||||
}
|
||||
|
||||
const statusTextClass: Record<string, string> = {
|
||||
connected: 'text-green-400',
|
||||
degraded: 'text-yellow-400',
|
||||
offline: 'text-red-400',
|
||||
const statusTone: Record<string, 'online' | 'warn' | 'offline'> = {
|
||||
connected: 'online',
|
||||
degraded: 'warn',
|
||||
offline: 'offline',
|
||||
}
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
@@ -108,94 +108,138 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Server class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Server Overview</h1>
|
||||
<p class="text-sm text-neutral-400 mt-0.5">
|
||||
{{ filteredServers.length }} server{{ filteredServers.length !== 1 ? 's' : '' }}
|
||||
<template v-if="statusFilter !== 'all'"> ({{ statusFilter }})</template>
|
||||
</p>
|
||||
<div class="pas">
|
||||
<!-- Page head -->
|
||||
<div class="pas__head">
|
||||
<div class="pas__head-id">
|
||||
<div class="pas__chip">
|
||||
<Icon name="server" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform admin</div>
|
||||
<h1 class="pas__title">Server overview</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div 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 by server name, email, or IP..."
|
||||
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>
|
||||
<select
|
||||
<div class="pas__filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by server name, email, or IP"
|
||||
icon="search"
|
||||
style="flex: 1; max-width: 360px"
|
||||
/>
|
||||
<Select
|
||||
v-model="statusFilter"
|
||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" :subtitle="filteredServers.length + ' server' + (filteredServers.length !== 1 ? 's' : '') + (statusFilter !== 'all' ? ' · ' + statusFilter : '')">
|
||||
<table class="pas__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">Server Name</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Owner Email</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Connection Type</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">Server IP</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Game Port</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Last Heartbeat</th>
|
||||
<tr class="pas__thead-row">
|
||||
<th class="pas__th">Server name</th>
|
||||
<th class="pas__th">Owner email</th>
|
||||
<th class="pas__th">Connection type</th>
|
||||
<th class="pas__th">Status</th>
|
||||
<th class="pas__th">Server IP</th>
|
||||
<th class="pas__th">Game port</th>
|
||||
<th class="pas__th">Last heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="filteredServers.length === 0 && !isLoading">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="searchQuery">No servers matching "{{ searchQuery }}"</template>
|
||||
<template v-else-if="statusFilter !== 'all'">No {{ statusFilter }} servers found.</template>
|
||||
<template v-else>No servers found.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading servers...</td>
|
||||
<td colspan="7" class="pas__td-empty">Loading servers...</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredServers.length === 0">
|
||||
<td colspan="7" class="pas__td-es">
|
||||
<EmptyState
|
||||
icon="server"
|
||||
:title="searchQuery ? 'No matching servers' : (statusFilter !== 'all' ? 'No ' + statusFilter + ' servers' : 'No servers found')"
|
||||
:description="searchQuery ? 'Try a different search term.' : 'Servers will appear here once connected.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="srv in filteredServers"
|
||||
:key="srv.license_id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
class="pas__tr"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name || 'Unnamed' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.owner_email }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
||||
:class="connectionBadgeClass[srv.connection_type] || 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<td class="pas__td pas__td--primary">{{ srv.server_name ?? 'Unnamed' }}</td>
|
||||
<td class="pas__td pas__td--secondary">{{ srv.owner_email }}</td>
|
||||
<td class="pas__td">
|
||||
<Badge :tone="connTypeTone[srv.connection_type] ?? 'neutral'">
|
||||
{{ srv.connection_type.replace('_', ' ') }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.connection_status] || 'bg-neutral-500'" />
|
||||
<span class="text-sm capitalize" :class="statusTextClass[srv.connection_status] || 'text-neutral-400'">
|
||||
{{ srv.connection_status }}
|
||||
</span>
|
||||
<td class="pas__td">
|
||||
<div class="pas__status">
|
||||
<StatusDot :tone="statusTone[srv.connection_status] ?? 'neutral'" :pulse="srv.connection_status === 'connected'" />
|
||||
<span class="pas__status-label" :data-tone="srv.connection_status">{{ srv.connection_status }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }}</td>
|
||||
<td class="pas__td pas__td--mono">{{ srv.server_ip ?? '—' }}</td>
|
||||
<td class="pas__td pas__td--mono">{{ srv.game_port ?? '—' }}</td>
|
||||
<td class="pas__td pas__td--secondary">
|
||||
{{ srv.plugin_last_seen
|
||||
? relativeTime(srv.plugin_last_seen)
|
||||
: srv.companion_last_seen
|
||||
? relativeTime(srv.companion_last_seen)
|
||||
: 'Never' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pas { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.pas__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.pas__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pas__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);
|
||||
}
|
||||
.pas__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.pas__filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Table */
|
||||
.pas__table { width: 100%; border-collapse: collapse; }
|
||||
.pas__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pas__th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pas__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||
.pas__tr:last-child { border-bottom: 0; }
|
||||
.pas__tr:hover { background: var(--surface-hover); }
|
||||
.pas__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pas__td--primary { font-weight: 500; }
|
||||
.pas__td--secondary { color: var(--text-secondary); }
|
||||
.pas__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pas__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.pas__td-es { padding: 0; }
|
||||
|
||||
/* Status cell */
|
||||
.pas__status { display: flex; align-items: center; gap: 7px; }
|
||||
.pas__status-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.pas__status-label[data-tone="connected"] { color: var(--status-online); }
|
||||
.pas__status-label[data-tone="degraded"] { color: var(--status-warn); }
|
||||
.pas__status-label[data-tone="offline"] { color: var(--status-offline); }
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { CreditCard, Package, DollarSign, Users } from 'lucide-vue-next'
|
||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -58,89 +62,147 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<CreditCard class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Subscriptions</h1>
|
||||
<p class="text-sm text-neutral-400 mt-0.5">Module subscription overview and subscriber details.</p>
|
||||
<div class="pasub">
|
||||
<!-- Page head -->
|
||||
<div class="pasub__head">
|
||||
<div class="pasub__head-id">
|
||||
<div class="pasub__chip">
|
||||
<Icon name="credit-card" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform admin</div>
|
||||
<h1 class="pasub__title">Subscriptions</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Total Subscribers -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
||||
<Users class="w-4 h-4 text-oxide-400" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400">Total Subscribers</p>
|
||||
</div>
|
||||
<div v-if="isLoading" class="h-8 w-16 bg-neutral-800 rounded animate-pulse" />
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeLocaleString(totalSubscribers) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total MRR -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-green-500/10">
|
||||
<DollarSign class="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400">Total MRR</p>
|
||||
</div>
|
||||
<div v-if="isLoading" class="h-8 w-24 bg-neutral-800 rounded animate-pulse" />
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeCurrency(totalMrr, '$') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Per-Module Cards -->
|
||||
<div
|
||||
<!-- KPI row -->
|
||||
<div class="pasub__kpis">
|
||||
<StatCard
|
||||
icon="users"
|
||||
label="Total subscribers"
|
||||
:value="isLoading ? '—' : safeLocaleString(totalSubscribers)"
|
||||
/>
|
||||
<StatCard
|
||||
icon="dollar-sign"
|
||||
label="Total MRR"
|
||||
:value="isLoading ? '—' : safeCurrency(totalMrr, '$')"
|
||||
/>
|
||||
<StatCard
|
||||
v-for="mod in moduleBreakdown"
|
||||
:key="mod.name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
||||
<Package class="w-4 h-4 text-oxide-400" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400 truncate">{{ mod.name }}</p>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-neutral-100">{{ mod.count }}</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">subscribers</p>
|
||||
</div>
|
||||
icon="package"
|
||||
:label="mod.name"
|
||||
:value="String(mod.count)"
|
||||
note="subscribers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Module breakdown summary -->
|
||||
<Panel v-if="moduleBreakdown.length > 0" title="Module breakdown" subtitle="Subscribers per module">
|
||||
<div class="pasub__mods">
|
||||
<div
|
||||
v-for="mod in moduleBreakdown"
|
||||
:key="mod.name"
|
||||
class="pasub__mod-row"
|
||||
>
|
||||
<div class="pasub__mod-icon">
|
||||
<Icon name="package" :size="14" :stroke-width="2" />
|
||||
</div>
|
||||
<span class="pasub__mod-name">{{ mod.name }}</span>
|
||||
<Badge tone="neutral" :mono="true">{{ mod.count }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" title="All subscriptions" :subtitle="subscriptions.length + ' total'">
|
||||
<table class="pasub__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">Owner Email</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Module Name</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">License ID</th>
|
||||
<tr class="pasub__thead-row">
|
||||
<th class="pasub__th">Owner email</th>
|
||||
<th class="pasub__th">Module name</th>
|
||||
<th class="pasub__th">License ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="subscriptions.length === 0 && !isLoading">
|
||||
<td colspan="3" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
No subscriptions found.
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="3" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading subscriptions...</td>
|
||||
<td colspan="3" class="pasub__td-empty">Loading subscriptions...</td>
|
||||
</tr>
|
||||
<tr v-else-if="subscriptions.length === 0">
|
||||
<td colspan="3" class="pasub__td-es">
|
||||
<EmptyState
|
||||
icon="credit-card"
|
||||
title="No subscriptions found"
|
||||
description="Module subscriptions will appear here once customers subscribe."
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(sub, idx) in subscriptions"
|
||||
:key="`${sub.license_id}-${sub.module_name}-${idx}`"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
class="pasub__tr"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ sub.owner_email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ sub.module_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ sub.license_id }}</td>
|
||||
<td class="pasub__td">{{ sub.owner_email }}</td>
|
||||
<td class="pasub__td pasub__td--secondary">{{ sub.module_name }}</td>
|
||||
<td class="pasub__td pasub__td--mono">{{ sub.license_id }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pasub { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.pasub__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.pasub__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pasub__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);
|
||||
}
|
||||
.pasub__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* KPI row */
|
||||
.pasub__kpis { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 13px; }
|
||||
|
||||
/* Module breakdown */
|
||||
.pasub__mods { display: flex; flex-direction: column; gap: 2px; }
|
||||
.pasub__mod-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 6px; border-radius: var(--radius-md);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.pasub__mod-row:hover { background: var(--surface-hover); }
|
||||
.pasub__mod-icon {
|
||||
width: 26px; height: 26px; flex: none; border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent-text); background: var(--accent-soft);
|
||||
}
|
||||
.pasub__mod-name { flex: 1; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
|
||||
/* Table */
|
||||
.pasub__table { width: 100%; border-collapse: collapse; }
|
||||
.pasub__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pasub__th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pasub__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||
.pasub__tr:last-child { border-bottom: 0; }
|
||||
.pasub__tr:hover { background: var(--surface-hover); }
|
||||
.pasub__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pasub__td--secondary { color: var(--text-secondary); }
|
||||
.pasub__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pasub__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.pasub__td-es { padding: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Users, Search, ShieldCheck, ShieldOff, UserX, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -114,126 +120,155 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">User Management</h1>
|
||||
<p class="text-sm text-neutral-400 mt-0.5">{{ total }} registered users</p>
|
||||
<div class="pau">
|
||||
<!-- Page head -->
|
||||
<div class="pau__head">
|
||||
<div class="pau__head-id">
|
||||
<div class="pau__chip">
|
||||
<Icon name="users" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform admin</div>
|
||||
<h1 class="pau__title">User management</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div 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 by email or username..."
|
||||
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 class="pau__filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by email or username"
|
||||
icon="search"
|
||||
style="flex: 1; max-width: 360px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" :subtitle="total + ' registered users'">
|
||||
<table class="pau__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">Email</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Username</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Super Admin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Licenses</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Last Login</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr class="pau__thead-row">
|
||||
<th class="pau__th">Email</th>
|
||||
<th class="pau__th">Username</th>
|
||||
<th class="pau__th">Super admin</th>
|
||||
<th class="pau__th">Licenses</th>
|
||||
<th class="pau__th">Created</th>
|
||||
<th class="pau__th">Last login</th>
|
||||
<th class="pau__th pau__th--right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="users.length === 0 && !isLoading">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="searchQuery">No users matching "{{ searchQuery }}"</template>
|
||||
<template v-else>No users found.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading users...</td>
|
||||
<td colspan="7" class="pau__td-empty">Loading users...</td>
|
||||
</tr>
|
||||
<tr v-else-if="users.length === 0">
|
||||
<td colspan="7" class="pau__td-es">
|
||||
<EmptyState
|
||||
icon="users"
|
||||
:title="searchQuery ? 'No matching users' : 'No users found'"
|
||||
:description="searchQuery ? 'Try a different search term.' : 'Registered users will appear here.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
:class="{ 'opacity-50': false }"
|
||||
class="pau__tr"
|
||||
:class="{ 'pau__tr--disabled': false }"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ user.email }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="user.is_super_admin"
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full bg-oxide-500/10 text-oxide-400"
|
||||
>
|
||||
<ShieldCheck class="w-3 h-3" />
|
||||
Super Admin
|
||||
</span>
|
||||
<span v-else class="text-xs text-neutral-600">—</span>
|
||||
<td class="pau__td">{{ user.email }}</td>
|
||||
<td class="pau__td pau__td--secondary">{{ user.username }}</td>
|
||||
<td class="pau__td">
|
||||
<Badge v-if="user.is_super_admin" tone="accent" icon="shield">Super admin</Badge>
|
||||
<span v-else class="pau__empty-cell">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.license_count }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(user.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login_at) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
<td class="pau__td pau__td--mono">{{ user.license_count }}</td>
|
||||
<td class="pau__td pau__td--secondary">{{ formatDate(user.created_at) }}</td>
|
||||
<td class="pau__td pau__td--secondary">{{ formatLastLogin(user.last_login_at) }}</td>
|
||||
<td class="pau__td pau__td--actions">
|
||||
<div class="pau__actions">
|
||||
<IconButton
|
||||
:icon="user.is_super_admin ? 'shield' : 'shield'"
|
||||
:variant="user.is_super_admin ? 'accent' : 'ghost'"
|
||||
size="sm"
|
||||
:label="user.is_super_admin ? 'Remove super admin' : 'Grant super admin'"
|
||||
:disabled="false"
|
||||
@click="toggleSuperAdmin(user)"
|
||||
/>
|
||||
<IconButton
|
||||
icon="ban"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Disable account"
|
||||
:disabled="false"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="user.is_super_admin
|
||||
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
||||
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-700'"
|
||||
:title="user.is_super_admin ? 'Remove Super Admin' : 'Grant Super Admin'"
|
||||
>
|
||||
<ShieldCheck v-if="!user.is_super_admin" class="w-4 h-4" />
|
||||
<ShieldOff v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="disableAccount(user)"
|
||||
:disabled="false"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:cursor-not-allowed rounded transition-colors"
|
||||
title="Disable Account"
|
||||
>
|
||||
<UserX class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-neutral-500">
|
||||
Page {{ page }} of {{ totalPages }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="page <= 1"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="page >= totalPages"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="pau__pager">
|
||||
<span class="pau__pager-info">Page {{ page }} of {{ totalPages }}</span>
|
||||
<div class="pau__pager-btns">
|
||||
<Button variant="secondary" size="sm" icon="chevron-left" :disabled="page <= 1" @click="prevPage">Prev</Button>
|
||||
<Button variant="secondary" size="sm" icon-right="chevron-right" :disabled="page >= totalPages" @click="nextPage">Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pau { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.pau__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.pau__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pau__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);
|
||||
}
|
||||
.pau__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.pau__filters { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
/* Table */
|
||||
.pau__table { width: 100%; border-collapse: collapse; }
|
||||
.pau__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pau__th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pau__th--right { text-align: right; }
|
||||
.pau__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||
.pau__tr:last-child { border-bottom: 0; }
|
||||
.pau__tr:hover { background: var(--surface-hover); }
|
||||
.pau__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pau__td--secondary { color: var(--text-secondary); }
|
||||
.pau__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pau__td--actions { text-align: right; }
|
||||
.pau__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.pau__td-es { padding: 0; }
|
||||
|
||||
.pau__empty-cell { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
|
||||
/* Actions */
|
||||
.pau__actions { display: inline-flex; align-items: center; gap: 2px; }
|
||||
|
||||
/* Pagination */
|
||||
.pau__pager { display: flex; align-items: center; justify-content: space-between; }
|
||||
.pau__pager-info { font-size: var(--text-sm); color: var(--text-tertiary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.pau__pager-btns { display: flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Server, Users, Calendar, MessageCircle, Loader2, ExternalLink } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
|
||||
interface ServerInfo {
|
||||
server_name: string
|
||||
@@ -51,99 +58,225 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center min-h-screen">
|
||||
<Loader2 class="w-8 h-8 text-oxide-500 animate-spin" />
|
||||
<div class="si-page">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="si-state">
|
||||
<Icon name="loader" :size="28" class="si-spin" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="flex items-center justify-center min-h-screen p-6">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md text-center">
|
||||
<Server class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Server Not Found</h1>
|
||||
<p class="text-sm text-neutral-400">{{ error }}</p>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="si-state">
|
||||
<EmptyState
|
||||
icon="server"
|
||||
title="Server not found"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Server Info -->
|
||||
<div v-else-if="serverInfo" class="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<!-- Header Image -->
|
||||
<div v-if="serverInfo.header_image" class="rounded-lg overflow-hidden">
|
||||
<img :src="serverInfo.header_image" :alt="serverInfo.server_name" class="w-full h-64 object-cover" />
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<template v-else-if="serverInfo">
|
||||
<!-- Sticky nav bar -->
|
||||
<header class="si-bar">
|
||||
<div class="si-bar__inner">
|
||||
<Logo :size="22" :wordmark="true" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Server Name & Stats -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-100 mb-2">{{ serverInfo.server_name }}</h1>
|
||||
<div class="flex items-center gap-2 text-neutral-400">
|
||||
<Users class="w-4 h-4" />
|
||||
<span class="text-sm">{{ serverInfo.player_count }}/{{ serverInfo.max_players }} players online</span>
|
||||
<main class="si-main">
|
||||
<!-- Hero image -->
|
||||
<div v-if="serverInfo.header_image" class="si-hero">
|
||||
<img
|
||||
:src="serverInfo.header_image"
|
||||
:alt="serverInfo.server_name"
|
||||
class="si-hero__img"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Identity + connect -->
|
||||
<Panel>
|
||||
<div class="si-identity">
|
||||
<div class="si-identity__left">
|
||||
<h1 class="si-title">{{ serverInfo.server_name }}</h1>
|
||||
<p v-if="serverInfo.description" class="si-desc">
|
||||
{{ serverInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="external-link" size="md" @click="copyConnectUrl">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
@click="copyConnectUrl"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
Connect
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="si-kpis">
|
||||
<StatCard
|
||||
icon="users"
|
||||
label="Players online"
|
||||
:value="String(serverInfo.player_count)"
|
||||
:unit="'/' + serverInfo.max_players"
|
||||
/>
|
||||
<StatCard
|
||||
v-if="serverInfo.wipe_schedule"
|
||||
icon="calendar"
|
||||
label="Wipe schedule"
|
||||
:value="serverInfo.wipe_schedule"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="serverInfo.description" class="text-neutral-300 leading-relaxed">
|
||||
{{ serverInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- MOTD -->
|
||||
<Panel v-if="serverInfo.motd" title="Message of the day">
|
||||
<p class="si-motd">{{ serverInfo.motd }}</p>
|
||||
</Panel>
|
||||
|
||||
<!-- MOTD -->
|
||||
<div v-if="serverInfo.motd" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<h2 class="text-lg font-bold text-neutral-100 mb-3">Message of the Day</h2>
|
||||
<p class="text-neutral-300 whitespace-pre-line">{{ serverInfo.motd }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Wipe Schedule -->
|
||||
<div v-if="serverInfo.wipe_schedule" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Calendar class="w-5 h-5 text-oxide-500" />
|
||||
<h2 class="text-lg font-bold text-neutral-100">Wipe Schedule</h2>
|
||||
</div>
|
||||
<p class="text-neutral-300">{{ serverInfo.wipe_schedule }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mods -->
|
||||
<div v-if="serverInfo.mods.length > 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<h2 class="text-lg font-bold text-neutral-100 mb-3">Active Mods</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="mod in serverInfo.mods"
|
||||
:key="mod"
|
||||
class="px-3 py-1 bg-neutral-800 border border-neutral-700 rounded-full text-sm text-neutral-300"
|
||||
>
|
||||
{{ mod }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discord -->
|
||||
<div v-if="serverInfo.discord_invite" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<MessageCircle class="w-5 h-5 text-oxide-500" />
|
||||
<h2 class="text-lg font-bold text-neutral-100">Join our Discord</h2>
|
||||
<!-- Active mods -->
|
||||
<Panel v-if="serverInfo.mods.length > 0" title="Active mods">
|
||||
<div class="si-mods">
|
||||
<Badge
|
||||
v-for="mod in serverInfo.mods"
|
||||
:key="mod"
|
||||
tone="neutral"
|
||||
size="lg"
|
||||
>{{ mod }}</Badge>
|
||||
</div>
|
||||
<a
|
||||
:href="serverInfo.discord_invite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
Join
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Discord -->
|
||||
<Panel v-if="serverInfo.discord_invite" title="Community">
|
||||
<Alert tone="info" title="Join our Discord">
|
||||
<template #actions>
|
||||
<a
|
||||
:href="serverInfo.discord_invite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button size="sm" variant="secondary" icon="external-link">Join Discord</Button>
|
||||
</a>
|
||||
</template>
|
||||
</Alert>
|
||||
</Panel>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.si-page {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
/* Loading / error centering */
|
||||
.si-state {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.si-spin {
|
||||
color: var(--accent);
|
||||
animation: si-rotate 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes si-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.si-spin { animation: none; }
|
||||
}
|
||||
|
||||
/* Sticky brand bar */
|
||||
.si-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--surface-base);
|
||||
box-shadow: 0 1px 0 var(--border-subtle);
|
||||
}
|
||||
|
||||
.si-bar__inner {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.si-main {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* Hero image */
|
||||
.si-hero {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.si-hero__img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Identity block inside panel */
|
||||
.si-identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.si-identity__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.si-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.si-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* KPI row */
|
||||
.si-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* MOTD */
|
||||
.si-motd {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Mods */
|
||||
.si-mods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { Server, Users, Activity, TrendingUp, Search } from 'lucide-vue-next'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
|
||||
interface ServerStatus {
|
||||
server_name: string
|
||||
@@ -72,32 +80,16 @@ async function fetchStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-500'
|
||||
case 'degraded':
|
||||
return 'bg-yellow-500'
|
||||
default:
|
||||
return 'bg-red-500'
|
||||
}
|
||||
function getStatusTone(status: string): 'online' | 'offline' | 'warn' {
|
||||
if (status === 'online') return 'online'
|
||||
if (status === 'degraded') return 'warn'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'Online'
|
||||
case 'degraded':
|
||||
return 'Degraded'
|
||||
default:
|
||||
return 'Offline'
|
||||
}
|
||||
}
|
||||
|
||||
function getUptimeBadgeColor(uptime: number) {
|
||||
if (uptime >= 99) return 'bg-green-500/10 text-green-400'
|
||||
if (uptime >= 95) return 'bg-yellow-500/10 text-yellow-400'
|
||||
return 'bg-red-500/10 text-red-400'
|
||||
function getUptimeTone(uptime: number): 'online' | 'warn' | 'offline' {
|
||||
if (uptime >= 99) return 'online'
|
||||
if (uptime >= 95) return 'warn'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
function formatTimeUntil(isoDate: string | null): string {
|
||||
@@ -135,207 +127,376 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-950">
|
||||
<!-- Header -->
|
||||
<header class="bg-neutral-900 border-b border-neutral-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-100 flex items-center gap-3">
|
||||
<Activity class="w-8 h-8 text-oxide-500" />
|
||||
Corrosion Status
|
||||
</h1>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
Real-time status for all Corrosion-powered Rust servers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative w-full md:w-80">
|
||||
<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 servers..."
|
||||
class="w-full pl-10 pr-4 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 class="sp-page">
|
||||
<!-- Brand bar -->
|
||||
<header class="sp-bar">
|
||||
<div class="sp-bar__inner">
|
||||
<div class="sp-bar__brand">
|
||||
<Logo :size="22" :wordmark="true" />
|
||||
<div class="sp-bar__sub">Real-time status for all Corrosion-powered servers</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Health Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6" v-if="!loading">
|
||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
||||
<Server class="w-3.5 h-3.5" />
|
||||
Total Servers
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">
|
||||
{{ platformHealth.total_servers }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
||||
<Activity class="w-3.5 h-3.5" />
|
||||
Online Now
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-green-400">
|
||||
{{ platformHealth.online_servers }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
||||
<Users class="w-3.5 h-3.5" />
|
||||
Total Players
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-oxide-400">
|
||||
{{ platformHealth.total_players }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
||||
<TrendingUp class="w-3.5 h-3.5" />
|
||||
Platform Uptime
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">
|
||||
{{ safeFixed(platformHealth.uptime_percent, 1) }}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="sp-bar__search">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search servers..."
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Server Grid -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block w-8 h-8 border-4 border-oxide-500/20 border-t-oxide-500 rounded-full animate-spin"></div>
|
||||
<p class="text-neutral-400 mt-4">Loading server status...</p>
|
||||
<!-- Platform KPIs -->
|
||||
<div v-if="!loading" class="sp-kpis">
|
||||
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
|
||||
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
|
||||
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
|
||||
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<main class="sp-main">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="sp-state">
|
||||
<Icon name="loader" :size="28" class="sp-spin" />
|
||||
<span class="sp-state__label">Loading server status...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
|
||||
<p class="text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<Alert v-else-if="error" tone="danger" :title="error ?? ''" />
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredServers.length === 0" class="text-center py-12">
|
||||
<Server class="w-16 h-16 text-neutral-600 mx-auto mb-4" />
|
||||
<p class="text-neutral-400 text-lg">
|
||||
{{ searchQuery ? 'No servers match your search' : 'No servers available yet' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Empty -->
|
||||
<EmptyState
|
||||
v-else-if="filteredServers.length === 0"
|
||||
icon="server"
|
||||
:title="searchQuery ? 'No servers match your search' : 'No servers available yet'"
|
||||
/>
|
||||
|
||||
<!-- Server Cards -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
<!-- Server grid -->
|
||||
<div v-else class="sp-grid">
|
||||
<Panel
|
||||
v-for="server in filteredServers"
|
||||
:key="server.subdomain"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-oxide-500/50 transition-colors"
|
||||
class="sp-card"
|
||||
>
|
||||
<!-- Server Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-neutral-100 truncate">
|
||||
{{ server.server_name }}
|
||||
</h3>
|
||||
<a
|
||||
:href="`https://${server.subdomain}.corrosionmgmt.com`"
|
||||
target="_blank"
|
||||
class="text-xs text-oxide-400 hover:text-oxide-300 truncate block"
|
||||
>
|
||||
{{ server.subdomain }}.corrosionmgmt.com
|
||||
</a>
|
||||
<div class="sp-card__body">
|
||||
<!-- Server name + link -->
|
||||
<div class="sp-card__head">
|
||||
<div class="sp-card__name-block">
|
||||
<h3 class="sp-card__name">{{ server.server_name }}</h3>
|
||||
<a
|
||||
:href="`https://${server.subdomain}.corrosionmgmt.com`"
|
||||
target="_blank"
|
||||
class="sp-card__link"
|
||||
>
|
||||
<Icon name="external-link" :size="11" />
|
||||
{{ server.subdomain }}.corrosionmgmt.com
|
||||
</a>
|
||||
</div>
|
||||
<StatusDot :tone="getStatusTone(server.status)" :size="10" :pulse="server.status === 'online'" />
|
||||
</div>
|
||||
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex items-center gap-2 ml-3">
|
||||
<div
|
||||
:class="getStatusColor(server.status)"
|
||||
class="w-2.5 h-2.5 rounded-full animate-pulse"
|
||||
></div>
|
||||
<span class="text-xs font-medium text-neutral-300">
|
||||
{{ getStatusText(server.status) }}
|
||||
</span>
|
||||
<!-- Description -->
|
||||
<p v-if="server.description" class="sp-card__desc">{{ server.description }}</p>
|
||||
|
||||
<!-- Player bar -->
|
||||
<div class="sp-players">
|
||||
<div class="sp-players__label">
|
||||
<Icon name="users" :size="12" />
|
||||
<span>{{ server.player_count }} / {{ server.max_players }} players</span>
|
||||
</div>
|
||||
<div class="sp-players__track">
|
||||
<div
|
||||
class="sp-players__fill"
|
||||
:style="{ width: `${(server.player_count / server.max_players) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div v-if="server.map_name" class="sp-card__meta">
|
||||
<Icon name="map" :size="12" />
|
||||
<span>{{ server.map_name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Wipe schedule -->
|
||||
<div v-if="server.wipe_schedule" class="sp-wipe">
|
||||
<div class="sp-wipe__label">Wipe schedule</div>
|
||||
<div class="sp-wipe__val">{{ server.wipe_schedule }}</div>
|
||||
<div v-if="server.next_wipe" class="sp-wipe__next">
|
||||
Next: {{ formatTimeUntil(server.next_wipe) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uptime badges -->
|
||||
<div class="sp-uptime">
|
||||
<Badge :tone="getUptimeTone(server.uptime_24h_percent)" :mono="true" size="lg">
|
||||
{{ safeFixed(server.uptime_24h_percent, 1) }}% <span class="sp-uptime__period">24h</span>
|
||||
</Badge>
|
||||
<Badge :tone="getUptimeTone(server.uptime_7d_percent)" :mono="true" size="lg">
|
||||
{{ safeFixed(server.uptime_7d_percent, 1) }}% <span class="sp-uptime__period">7d</span>
|
||||
</Badge>
|
||||
<Badge :tone="getUptimeTone(server.uptime_30d_percent)" :mono="true" size="lg">
|
||||
{{ safeFixed(server.uptime_30d_percent, 1) }}% <span class="sp-uptime__period">30d</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="server.description" class="text-sm text-neutral-400 mb-4 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
|
||||
<!-- Player Count -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Users class="w-4 h-4 text-neutral-500" />
|
||||
<span class="text-sm text-neutral-300">
|
||||
{{ server.player_count }} / {{ server.max_players }} players
|
||||
</span>
|
||||
<div class="flex-1 bg-neutral-800 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-oxide-500 transition-all duration-300"
|
||||
:style="{ width: `${(server.player_count / server.max_players) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Info -->
|
||||
<div v-if="server.map_name" class="text-xs text-neutral-500 mb-3">
|
||||
Map: {{ server.map_name }}
|
||||
</div>
|
||||
|
||||
<!-- Wipe Schedule -->
|
||||
<div v-if="server.wipe_schedule" class="bg-neutral-800 rounded-lg p-3 mb-3">
|
||||
<div class="text-xs text-neutral-500 mb-1">Wipe Schedule</div>
|
||||
<div class="text-sm text-neutral-300">{{ server.wipe_schedule }}</div>
|
||||
<div v-if="server.next_wipe" class="text-xs text-oxide-400 mt-1">
|
||||
Next wipe: {{ formatTimeUntil(server.next_wipe) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uptime Badges -->
|
||||
<div class="flex gap-2">
|
||||
<div :class="getUptimeBadgeColor(server.uptime_24h_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_24h_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">24h</div>
|
||||
</div>
|
||||
<div :class="getUptimeBadgeColor(server.uptime_7d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_7d_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">7d</div>
|
||||
</div>
|
||||
<div :class="getUptimeBadgeColor(server.uptime_30d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_30d_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">30d</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh indicator -->
|
||||
<div class="text-center mt-8 text-xs text-neutral-600">
|
||||
Auto-refreshing every 10 seconds
|
||||
</div>
|
||||
<!-- Auto-refresh note -->
|
||||
<div class="sp-refresh-note">Auto-refreshing every 10 seconds</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-neutral-900 border-t border-neutral-800 mt-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center">
|
||||
<p class="text-neutral-400 text-sm mb-2">
|
||||
Powered by
|
||||
<a
|
||||
href="https://panel.corrosionmgmt.com"
|
||||
class="text-oxide-400 hover:text-oxide-300 font-medium"
|
||||
target="_blank"
|
||||
>
|
||||
Corrosion
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-neutral-600 text-xs">
|
||||
The complete server management platform for Rust game servers
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sp-page {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Brand bar */
|
||||
.sp-bar {
|
||||
background: var(--surface-base);
|
||||
box-shadow: 0 1px 0 var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sp-bar__inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sp-bar__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sp-bar__sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sp-bar__search {
|
||||
width: 260px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Platform KPIs */
|
||||
.sp-kpis {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: var(--space-5) var(--space-6) 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.sp-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.sp-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.sp-spin {
|
||||
color: var(--accent);
|
||||
animation: sp-rotate 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sp-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sp-spin { animation: none; }
|
||||
}
|
||||
|
||||
.sp-state__label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Server grid */
|
||||
.sp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
/* Server card body */
|
||||
.sp-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sp-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sp-card__name-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sp-card__name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sp-card__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-text);
|
||||
transition: color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sp-card__link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sp-card__desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-card__meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Player progress bar */
|
||||
.sp-players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sp-players__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sp-players__track {
|
||||
height: 4px;
|
||||
background: var(--surface-raised-2);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-players__fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: width 0.4s var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Wipe schedule block */
|
||||
.sp-wipe {
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sp-wipe__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sp-wipe__val {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sp-wipe__next {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-text);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Uptime badges row */
|
||||
.sp-uptime {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sp-uptime__period {
|
||||
opacity: 0.65;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Auto-refresh note */
|
||||
.sp-refresh-note {
|
||||
text-align: center;
|
||||
margin-top: var(--space-8);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.sp-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.sp-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sp-grid { grid-template-columns: 1fr; }
|
||||
.sp-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||
.sp-bar__search { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
|
||||
import { ShoppingCart, Package, Filter, X, AlertCircle, ExternalLink, Check } from 'lucide-vue-next'
|
||||
import { safeCurrency } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const subdomain = computed(() => route.params.subdomain as string)
|
||||
@@ -29,7 +37,7 @@ const categories = computed(() => {
|
||||
})
|
||||
return Array.from(cats).map(name => ({
|
||||
value: name,
|
||||
label: name === 'all' ? 'All Items' : name
|
||||
label: name === 'all' ? 'All items' : name
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -142,14 +150,14 @@ function formatPrice(price: number): string {
|
||||
return safeCurrency(price, '$')
|
||||
}
|
||||
|
||||
function itemTypeBadgeClass(itemType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
kit: 'bg-blue-500/15 text-blue-400',
|
||||
rank: 'bg-purple-500/15 text-purple-400',
|
||||
currency: 'bg-green-500/15 text-green-400',
|
||||
custom_command: 'bg-orange-500/15 text-orange-400',
|
||||
function itemTypeTone(itemType: string): 'info' | 'wiping' | 'online' | 'warn' | 'neutral' {
|
||||
const tones: Record<string, 'info' | 'wiping' | 'online' | 'warn' | 'neutral'> = {
|
||||
kit: 'info',
|
||||
rank: 'wiping',
|
||||
currency: 'online',
|
||||
custom_command: 'warn',
|
||||
}
|
||||
return colors[itemType] || 'bg-neutral-700/50 text-neutral-400'
|
||||
return tones[itemType] ?? 'neutral'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -158,264 +166,631 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-b from-neutral-950 to-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="bg-neutral-950/80 backdrop-blur-sm border-b border-neutral-800 sticky top-0 z-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<ShoppingCart class="w-6 h-6 text-oxide-500" />
|
||||
<h1 class="text-3xl font-bold text-neutral-100">
|
||||
{{ storeInfo?.store_name || 'Server Store' }}
|
||||
</h1>
|
||||
<div class="sv-page">
|
||||
<!-- Sticky store header -->
|
||||
<header class="sv-bar">
|
||||
<div class="sv-bar__inner">
|
||||
<div class="sv-bar__brand">
|
||||
<Logo :size="20" :wordmark="true" />
|
||||
<div class="sv-bar__divider" />
|
||||
<div class="sv-bar__store">
|
||||
<Icon name="shopping-cart" :size="16" />
|
||||
<span class="sv-bar__name">{{ storeInfo?.store_name ?? 'Server store' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="storeInfo?.description" class="text-neutral-400 max-w-3xl">
|
||||
{{ storeInfo.description }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Store description -->
|
||||
<div v-if="storeInfo?.description" class="sv-desc-wrap">
|
||||
<p class="sv-desc">{{ storeInfo.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<div class="inline-block w-8 h-8 border-4 border-oxide-500 border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p class="text-neutral-400">Loading store...</p>
|
||||
</div>
|
||||
<div class="sv-body">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="sv-state">
|
||||
<Icon name="loader" :size="28" class="sv-spin" />
|
||||
<span class="sv-state__label">Loading store...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="storeError" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<AlertCircle class="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2">Store Unavailable</h2>
|
||||
<p class="text-neutral-400 mb-6">{{ storeError }}</p>
|
||||
<button
|
||||
@click="loadStore"
|
||||
class="px-6 py-3 bg-oxide-600 hover:bg-oxide-700 text-white font-medium rounded-lg transition-colors"
|
||||
<!-- Error -->
|
||||
<div v-else-if="storeError" class="sv-error">
|
||||
<EmptyState
|
||||
icon="shopping-bag"
|
||||
title="Store unavailable"
|
||||
:description="storeError"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<template #action>
|
||||
<Button icon="refresh-cw" variant="secondary" @click="loadStore">Retry</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- Store content -->
|
||||
<div v-else-if="storeInfo">
|
||||
<template v-else-if="storeInfo">
|
||||
<!-- Category filter -->
|
||||
<div v-if="categories.length > 1" class="mb-6 flex items-center gap-3">
|
||||
<Filter class="w-4 h-4 text-neutral-500" />
|
||||
<select
|
||||
<div v-if="categories.length > 1" class="sv-filter">
|
||||
<Icon name="list" :size="14" class="sv-filter__icon" />
|
||||
<Select
|
||||
v-model="selectedCategory"
|
||||
class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
size="sm"
|
||||
:options="categories"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Items grid -->
|
||||
<div v-if="filteredItems.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-if="filteredItems.length > 0" class="sv-grid">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-oxide-500/50 transition-all group shadow-lg hover:shadow-oxide-500/10"
|
||||
class="sv-item"
|
||||
>
|
||||
<!-- Item image -->
|
||||
<div class="relative h-48 bg-gradient-to-br from-neutral-800 to-neutral-900 overflow-hidden">
|
||||
<div class="sv-item__img-wrap">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
class="sv-item__img"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Package class="w-16 h-16 text-neutral-700" />
|
||||
<div v-else class="sv-item__img-ph">
|
||||
<Icon name="package" :size="32" :stroke-width="1.5" />
|
||||
</div>
|
||||
<div v-if="item.category_name" class="absolute top-3 right-3">
|
||||
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-neutral-950/80 backdrop-blur-sm text-neutral-300 border border-neutral-700">
|
||||
{{ item.category_name }}
|
||||
</span>
|
||||
<div v-if="item.category_name" class="sv-item__cat">
|
||||
<Badge tone="neutral">{{ item.category_name }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item details -->
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="text-lg font-bold text-neutral-100 group-hover:text-oxide-400 transition-colors">
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<span class="text-xl font-bold text-oxide-400 shrink-0">
|
||||
{{ formatPrice(item.price) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.description" class="text-sm text-neutral-400 line-clamp-2 leading-relaxed">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<div class="sv-item__body">
|
||||
<div class="sv-item__top">
|
||||
<h3 class="sv-item__name">{{ item.name }}</h3>
|
||||
<span class="sv-item__price">{{ formatPrice(item.price) }}</span>
|
||||
</div>
|
||||
<p v-if="item.description" class="sv-item__desc">{{ item.description }}</p>
|
||||
|
||||
<!-- Item type badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium px-2 py-1 rounded" :class="itemTypeBadgeClass(item.item_type)">
|
||||
<!-- Type badge + limit -->
|
||||
<div class="sv-item__meta">
|
||||
<Badge :tone="itemTypeTone(item.item_type)">
|
||||
{{ item.item_type.replace('_', ' ') }}
|
||||
</Badge>
|
||||
<span v-if="item.limit_per_player" class="sv-item__limit">
|
||||
Limit {{ item.limit_per_player }} per player
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Purchase limit indicator -->
|
||||
<div v-if="item.limit_per_player" class="text-xs text-neutral-500">
|
||||
Limited to {{ item.limit_per_player }} per player
|
||||
</div>
|
||||
|
||||
<!-- Buy button -->
|
||||
<button
|
||||
<Button
|
||||
:block="true"
|
||||
icon="shopping-cart"
|
||||
@click="openPurchaseModal(item)"
|
||||
class="w-full py-3 text-sm font-semibold text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors shadow-lg shadow-oxide-500/20"
|
||||
>
|
||||
Buy Now
|
||||
</button>
|
||||
>Buy now</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-16 text-center">
|
||||
<Package class="w-16 h-16 text-neutral-600 mx-auto mb-4" />
|
||||
<h3 class="text-xl font-medium text-neutral-300 mb-2">No Items Available</h3>
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ selectedCategory === 'all' ? 'This store has no items at the moment.' : 'No items in this category.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty category -->
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="package"
|
||||
title="No items available"
|
||||
:description="selectedCategory === 'all' ? 'This store has no items at the moment.' : 'No items in this category.'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Modal -->
|
||||
<!-- Purchase modal -->
|
||||
<div
|
||||
v-if="showPurchaseModal && selectedItem"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
class="sv-modal-scrim"
|
||||
@click.self="closePurchaseModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl max-w-lg w-full shadow-2xl">
|
||||
<!-- Modal Header -->
|
||||
<div class="border-b border-neutral-800 px-6 py-5 flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold text-neutral-100 mb-1">Complete Purchase</h2>
|
||||
<p class="text-sm text-neutral-400">You'll be redirected to PayPal to complete payment</p>
|
||||
<div class="sv-modal">
|
||||
<!-- Modal header -->
|
||||
<div class="sv-modal__head">
|
||||
<div>
|
||||
<div class="sv-modal__title">Complete purchase</div>
|
||||
<div class="sv-modal__sub">You'll be redirected to PayPal to complete payment</div>
|
||||
</div>
|
||||
<button
|
||||
class="sv-modal__close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
@click="closePurchaseModal"
|
||||
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
<Icon name="x" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Modal body -->
|
||||
<div class="sv-modal__body">
|
||||
<!-- Item preview -->
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-start gap-4">
|
||||
<div class="w-20 h-20 bg-neutral-800 rounded-lg overflow-hidden shrink-0">
|
||||
<div class="sv-modal__item">
|
||||
<div class="sv-modal__thumb">
|
||||
<img
|
||||
v-if="selectedItem.image_url"
|
||||
:src="selectedItem.image_url"
|
||||
:alt="selectedItem.name"
|
||||
class="w-full h-full object-cover"
|
||||
class="sv-modal__thumb-img"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Package class="w-8 h-8 text-neutral-600" />
|
||||
<div v-else class="sv-modal__thumb-ph">
|
||||
<Icon name="package" :size="20" :stroke-width="1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-neutral-100 mb-1">{{ selectedItem.name }}</h3>
|
||||
<p v-if="selectedItem.description" class="text-sm text-neutral-400 line-clamp-2 mb-2">
|
||||
{{ selectedItem.description }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-oxide-400">{{ formatPrice(selectedItem.price) }}</p>
|
||||
<div class="sv-modal__item-info">
|
||||
<div class="sv-modal__item-name">{{ selectedItem.name }}</div>
|
||||
<p v-if="selectedItem.description" class="sv-modal__item-desc">{{ selectedItem.description }}</p>
|
||||
<div class="sv-modal__item-price">{{ formatPrice(selectedItem.price) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steam ID input -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">
|
||||
Steam ID <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="steamId"
|
||||
type="text"
|
||||
placeholder="76561198012345678"
|
||||
maxlength="17"
|
||||
class="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
:class="{ 'border-red-500': purchaseError && !validateSteamId() }"
|
||||
/>
|
||||
<p class="text-xs text-neutral-500 mt-1.5">
|
||||
Required for item delivery. Must be your 17-digit Steam ID.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Steam ID -->
|
||||
<Input
|
||||
v-model="steamId"
|
||||
label="Steam ID"
|
||||
:required="true"
|
||||
:mono="true"
|
||||
placeholder="76561198012345678"
|
||||
:error="purchaseError && !validateSteamId() ? 'Must be your 17-digit Steam ID' : undefined"
|
||||
hint="Required for item delivery."
|
||||
/>
|
||||
|
||||
<!-- Player name input -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">
|
||||
Player Name (Optional)
|
||||
</label>
|
||||
<input
|
||||
v-model="playerName"
|
||||
type="text"
|
||||
placeholder="Your in-game name"
|
||||
class="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<!-- Player name -->
|
||||
<Input
|
||||
v-model="playerName"
|
||||
label="Player name (optional)"
|
||||
placeholder="Your in-game name"
|
||||
/>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="purchaseError" class="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
||||
<AlertCircle class="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-red-400">{{ purchaseError }}</p>
|
||||
</div>
|
||||
<!-- Purchase error -->
|
||||
<Alert v-if="purchaseError" tone="danger" :title="purchaseError" />
|
||||
|
||||
<!-- Legal disclaimer -->
|
||||
<div class="bg-neutral-800/30 border border-neutral-700 rounded-lg p-4">
|
||||
<p class="text-xs text-neutral-500 leading-relaxed space-y-1.5">
|
||||
<span class="flex items-start gap-1.5">
|
||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
||||
<span>Items will be delivered automatically to your in-game character after payment</span>
|
||||
</span>
|
||||
<span class="flex items-start gap-1.5">
|
||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
||||
<Panel variant="raised">
|
||||
<div class="sv-modal__terms">
|
||||
<div class="sv-modal__term">
|
||||
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||
<span>Items delivered automatically to your character after payment</span>
|
||||
</div>
|
||||
<div class="sv-modal__term">
|
||||
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||
<span>All purchases are final and non-refundable</span>
|
||||
</span>
|
||||
<span class="flex items-start gap-1.5">
|
||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
||||
</div>
|
||||
<div class="sv-modal__term">
|
||||
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||
<span>You must be logged into the server to receive items</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="border-t border-neutral-800 px-6 py-5 flex items-center justify-between gap-4">
|
||||
<button
|
||||
@click="closePurchaseModal"
|
||||
<!-- Modal footer -->
|
||||
<div class="sv-modal__foot">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="isPurchasing"
|
||||
class="px-6 py-3 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmPurchase"
|
||||
@click="closePurchaseModal"
|
||||
>Cancel</Button>
|
||||
<Button
|
||||
icon="external-link"
|
||||
:disabled="isPurchasing || !steamId.trim()"
|
||||
class="flex items-center gap-2 px-8 py-3 text-sm font-semibold text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-oxide-500/20"
|
||||
>
|
||||
<ExternalLink v-if="!isPurchasing" class="w-4 h-4" />
|
||||
<span>{{ isPurchasing ? 'Processing...' : 'Proceed to PayPal' }}</span>
|
||||
</button>
|
||||
:loading="isPurchasing"
|
||||
@click="confirmPurchase"
|
||||
>{{ isPurchasing ? 'Processing...' : 'Proceed to PayPal' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-16 py-8 border-t border-neutral-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center gap-2 text-neutral-600 text-sm">
|
||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sv-page {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
/* Store bar */
|
||||
.sv-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--surface-base);
|
||||
box-shadow: 0 1px 0 var(--border-subtle);
|
||||
}
|
||||
|
||||
.sv-bar__inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.sv-bar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sv-bar__divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-default);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sv-bar__store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sv-bar__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Description strip */
|
||||
.sv-desc-wrap {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) var(--space-6) 0;
|
||||
}
|
||||
|
||||
.sv-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.sv-body {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.sv-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.sv-spin {
|
||||
color: var(--accent);
|
||||
animation: sv-rotate 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sv-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sv-spin { animation: none; }
|
||||
}
|
||||
|
||||
.sv-state__label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.sv-error {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* Category filter */
|
||||
.sv-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.sv-filter__icon {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Items grid */
|
||||
.sv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* Item card */
|
||||
.sv-item {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default), var(--shadow-sm);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow var(--dur-fast) var(--ease-standard),
|
||||
transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sv-item:hover {
|
||||
box-shadow: var(--ring-strong), var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sv-item__img-wrap {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
background: var(--surface-raised);
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sv-item__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.3s var(--ease-standard);
|
||||
}
|
||||
|
||||
.sv-item:hover .sv-item__img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.sv-item__img-ph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sv-item__cat {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
}
|
||||
|
||||
.sv-item__body {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sv-item__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sv-item__name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sv-item__price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--accent-text);
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sv-item__desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sv-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sv-item__limit {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal scrim */
|
||||
.sv-modal-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.sv-modal {
|
||||
background: var(--surface-overlay);
|
||||
box-shadow: var(--shadow-pop);
|
||||
border-radius: var(--radius-xl);
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--space-8));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sv-modal__head {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sv-modal__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sv-modal__sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.sv-modal__close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sv-modal__close:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sv-modal__close:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.sv-modal__body {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* Item preview in modal */
|
||||
.sv-modal__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--ring-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.sv-modal__thumb {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-2);
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sv-modal__thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sv-modal__thumb-ph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sv-modal__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sv-modal__item-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sv-modal__item-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sv-modal__item-price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--accent-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Terms list */
|
||||
.sv-modal__terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sv-modal__term {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sv-modal__term-icon {
|
||||
color: var(--accent-text);
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Modal footer */
|
||||
.sv-modal__foot {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user