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

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

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

View File

@@ -20,6 +20,9 @@ import {
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
Ban, Flag,
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
Pencil, Save, ShoppingBag, Target, User,
} from 'lucide-vue-next'
const props = withDefaults(
@@ -50,6 +53,11 @@ const registry: Record<string, Component> = {
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft,
ban: Ban, flag: Flag,
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
target: Target, user: User,
}
const cmp = computed<Component | null>(() => registry[props.name] ?? null)

View File

@@ -1,8 +1,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { AlertTriangle, Save, Loader2 } 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 Switch from '@/components/ds/forms/Switch.vue'
import Input from '@/components/ds/forms/Input.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
interface AlertConfig {
population_drop_enabled: boolean
@@ -62,15 +68,29 @@ async function saveConfig() {
}
}
function getSeverityColor(severity: string): string {
switch (severity) {
case 'info': return 'bg-blue-500/10 text-blue-400'
case 'warning': return 'bg-yellow-500/10 text-yellow-400'
case 'critical': return 'bg-red-500/10 text-red-400'
default: return 'bg-neutral-700/50 text-neutral-400'
function severityTone(severity: string): 'info' | 'warn' | 'offline' | 'neutral' {
if (severity === 'info') return 'info'
if (severity === 'warning') return 'warn'
if (severity === 'critical') return 'offline'
return 'neutral'
}
// Range input handler (range emits string, keep numeric in config)
function onThresholdInput(e: Event) {
const val = parseInt((e.target as HTMLInputElement).value, 10)
if (!isNaN(val)) {
config.value.population_drop_threshold_percent = val
}
}
// FPS threshold: DS Input binds string; sync via a string ref
const fpsStr = ref(String(config.value.fps_threshold))
function onFpsUpdate(val: string | undefined) {
fpsStr.value = val ?? ''
const n = parseInt(val ?? '', 10)
if (!isNaN(n)) config.value.fps_threshold = n
}
onMounted(() => {
fetchConfig()
fetchHistory()
@@ -78,156 +98,258 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<AlertTriangle class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Alerts</h1>
</div>
<!-- Alert Configuration -->
<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">Alert Configuration</h2>
<div v-if="isLoading" class="py-8 flex justify-center">
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
</div>
<div v-else class="space-y-6">
<!-- Population Drop Alert -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-neutral-200">Population Drop Alert</label>
<button
@click="config.population_drop_enabled = !config.population_drop_enabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="config.population_drop_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="config.population_drop_enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="config.population_drop_enabled">
<label class="block text-xs text-neutral-500 mb-2">Threshold (%)</label>
<input
v-model.number="config.population_drop_threshold_percent"
type="range"
min="10"
max="100"
class="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-oxide-500"
/>
<div class="text-xs text-neutral-400 mt-1">{{ config.population_drop_threshold_percent }}%</div>
</div>
</div>
<!-- FPS Degradation Alert -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-neutral-200">FPS Degradation Alert</label>
<button
@click="config.fps_degradation_enabled = !config.fps_degradation_enabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="config.fps_degradation_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="config.fps_degradation_enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="config.fps_degradation_enabled">
<label class="block text-xs text-neutral-500 mb-2">FPS Threshold</label>
<input
v-model.number="config.fps_threshold"
type="number"
min="10"
max="60"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
/>
</div>
</div>
<!-- Notification Channels -->
<div class="border-t border-neutral-800 pt-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Notification Channels</h3>
<div class="space-y-2">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="config.notify_discord"
type="checkbox"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
/>
<span class="text-sm text-neutral-200">Discord</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="config.notify_pushbullet"
type="checkbox"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
/>
<span class="text-sm text-neutral-200">Pushbullet</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="config.notify_email"
type="checkbox"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
/>
<span class="text-sm text-neutral-200">Email</span>
</label>
</div>
</div>
<!-- Save Button -->
<button
@click="saveConfig"
:disabled="isSaving"
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="isSaving" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" />
Save Configuration
</button>
<div class="alerts">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Monitoring</div>
<h1 class="page__title">Alerts</h1>
</div>
</div>
<!-- Alert 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">Alert History</h2>
<!-- Alert configuration -->
<Panel title="Alert configuration" subtitle="Trigger conditions and notification channels">
<div v-if="isLoading" class="loading-row">
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
</div>
<div v-if="history.length === 0" class="p-8 text-center text-neutral-500">
No alerts triggered yet.
<div v-else class="config-body">
<!-- Triggers section -->
<div class="config-section">
<div class="t-eyebrow config-section__eyebrow">Triggers</div>
<!-- Population drop -->
<div class="alert-rule">
<div class="alert-rule__head">
<div>
<div class="alert-rule__name">Population drop alert</div>
<div class="alert-rule__desc">Fire when player count falls by this percentage</div>
</div>
<Switch v-model="config.population_drop_enabled" />
</div>
<div v-if="config.population_drop_enabled" class="alert-rule__body">
<div class="range-field">
<input
type="range"
:value="config.population_drop_threshold_percent"
min="10"
max="100"
class="cc-range"
@input="onThresholdInput"
/>
<span class="range-value">{{ config.population_drop_threshold_percent }}%</span>
</div>
</div>
</div>
<!-- FPS degradation -->
<div class="alert-rule">
<div class="alert-rule__head">
<div>
<div class="alert-rule__name">FPS degradation alert</div>
<div class="alert-rule__desc">Fire when server FPS drops below threshold</div>
</div>
<Switch v-model="config.fps_degradation_enabled" />
</div>
<div v-if="config.fps_degradation_enabled" class="alert-rule__body">
<Input
:model-value="fpsStr"
label="FPS threshold"
type="number"
:mono="true"
hint="Minimum acceptable FPS (1060)"
@update:model-value="onFpsUpdate"
/>
</div>
</div>
</div>
<!-- Notification channels -->
<div class="config-section">
<div class="t-eyebrow config-section__eyebrow">Notification channels</div>
<div class="channels">
<Checkbox v-model="config.notify_discord" label="Discord" />
<Checkbox v-model="config.notify_pushbullet" label="Pushbullet" />
<Checkbox v-model="config.notify_email" label="Email" />
</div>
</div>
<!-- Save -->
<Button icon="save" :loading="isSaving" @click="saveConfig">
Save configuration
</Button>
</div>
<table v-else class="w-full">
<thead class="bg-neutral-800/50 border-b border-neutral-800">
</Panel>
<!-- Alert history -->
<Panel :flush-body="true" title="Alert history">
<EmptyState
v-if="history.length === 0"
icon="triangle-alert"
title="No alerts triggered"
description="Alerts will appear here when trigger conditions are met."
/>
<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">Triggered</th>
<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">Severity</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Message</th>
<th>Triggered</th>
<th>Type</th>
<th>Severity</th>
<th>Title</th>
<th>Message</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30">
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
:class="getSeverityColor(alert.severity)"
>
{{ alert.severity }}
</span>
<tbody>
<tr v-for="entry in history" :key="entry.id">
<td class="td-mono">{{ safeDate(entry.triggered_at) }}</td>
<td>{{ entry.alert_type }}</td>
<td>
<Badge :tone="severityTone(entry.severity)">{{ entry.severity }}</Badge>
</td>
<td class="px-4 py-3 text-sm text-neutral-200">{{ alert.title }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.message }}</td>
<td class="td-primary">{{ entry.title }}</td>
<td>{{ entry.message }}</td>
</tr>
</tbody>
</table>
</div>
</Panel>
</div>
</template>
<style scoped>
.alerts {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Page head */
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.page__title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
margin-top: 4px;
}
/* Loading row */
.loading-row {
display: flex;
justify-content: center;
padding: 40px;
}
/* Config body */
.config-body {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-section__eyebrow { margin-bottom: 4px; }
/* Alert rule card */
.alert-rule {
background: var(--surface-raised);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
overflow: hidden;
}
.alert-rule__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 15px;
}
.alert-rule__name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.alert-rule__desc {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.alert-rule__body {
border-top: 1px solid var(--border-subtle);
padding: 13px 15px;
}
/* Range slider */
.range-field {
display: flex;
align-items: center;
gap: 14px;
}
.cc-range {
flex: 1;
height: 6px;
border-radius: var(--radius-pill);
appearance: none;
background: var(--surface-active);
cursor: pointer;
accent-color: var(--accent);
outline: 0;
}
.range-value {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
color: var(--accent-text);
font-weight: 600;
min-width: 36px;
text-align: right;
}
/* Channels */
.channels {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 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; }
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
</style>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
@@ -8,6 +7,11 @@ import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import type { AnalyticsSummary, TimeseriesData } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
const authStore = useAuthStore()
@@ -54,9 +58,22 @@ const loadAnalytics = async () => {
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!timeseries.value) return
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
// Player count chart
if (playerChart.value) {
if (playerChartInstance) {
@@ -68,9 +85,9 @@ const renderCharts = () => {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' }
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
},
grid: {
left: '3%',
@@ -86,14 +103,14 @@ const renderCharts = () => {
day: 'numeric',
hour: '2-digit'
})),
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
series: [
{
@@ -101,14 +118,14 @@ const renderCharts = () => {
type: 'line',
data: timeseries.value.player_count,
smooth: true,
lineStyle: { color: '#CE422B', width: 2 },
lineStyle: { color: accent, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(206, 66, 43, 0.3)' },
{ offset: 1, color: 'rgba(206, 66, 43, 0)' }
{ offset: 0, color: accent + '55' },
{ offset: 1, color: accent + '00' }
])
},
itemStyle: { color: '#CE422B' }
itemStyle: { color: accent }
}
]
})
@@ -125,13 +142,13 @@ const renderCharts = () => {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' }
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
},
legend: {
data: ['FPS', 'Entities'],
textStyle: { color: '#a3a3a3' },
textStyle: { color: labelColor, fontFamily: mono },
top: 0
},
grid: {
@@ -148,25 +165,25 @@ const renderCharts = () => {
day: 'numeric',
hour: '2-digit'
})),
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: [
{
type: 'value',
name: 'FPS',
position: 'left',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
{
type: 'value',
name: 'Entities',
position: 'right',
axisLine: { lineStyle: { color: '#404040' } },
axisLine: { lineStyle: { color: axisLine } },
splitLine: { show: false },
axisLabel: { color: '#808080' }
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}
],
series: [
@@ -223,114 +240,209 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<div class="analytics-view">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<BarChart3 class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="downloadCSV"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<Download class="w-4 h-4" />
<div class="analytics-view__header">
<h1 class="analytics-view__title">Analytics</h1>
<div class="analytics-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['24h', '7d', '30d'] as const)"
:key="opt"
@click="timeRange = opt"
class="px-3 py-2 text-sm font-medium transition-colors"
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
</Button>
<Tabs
:items="(['24h', '7d', '30d'] as const).map(v => ({ value: v, label: v }))"
v-model="timeRange"
variant="pill"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading analytics...</div>
<div v-if="loading" class="analytics-view__loading">
<span class="analytics-view__loading-text">Loading analytics...</span>
</div>
<template v-else-if="summary">
<!-- Stat cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Users class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Peak Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ summary.peak_players }}</p>
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Avg Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p>
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Uptime</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p>
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Unique Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ summary.unique_players ?? '—' }}</p>
<p class="text-xs text-neutral-600 mt-1">Phase 2.2</p>
</div>
<div class="analytics-view__stats">
<StatCard
label="Peak players"
:value="summary.peak_players ?? '—'"
icon="users"
:note="`Last ${timeRange}`"
/>
<StatCard
label="Avg players"
:value="safeFixed(summary?.avg_players, 1)"
icon="trending-up"
:note="`Last ${timeRange}`"
/>
<StatCard
label="Uptime"
:value="safeFixed(summary?.uptime_percentage, 1)"
unit="%"
icon="clock"
:note="`Last ${timeRange}`"
/>
<StatCard
label="Unique players"
:value="summary.unique_players ?? '—'"
icon="bar-chart-3"
note="Phase 2.2"
/>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<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">Player Count Over Time</h2>
<div ref="playerChart" class="h-64"></div>
</div>
<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">Server Performance</h2>
<div ref="perfChart" class="h-64"></div>
</div>
<div class="analytics-view__charts">
<Panel title="Player count over time">
<div ref="playerChart" class="analytics-view__chart-area"></div>
</Panel>
<Panel title="Server performance">
<div ref="perfChart" class="analytics-view__chart-area"></div>
</Panel>
</div>
<!-- Player Retention -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
<p class="text-xs text-neutral-500 mb-1">New Players</p>
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
<!-- Player Retention placeholder -->
<Panel eyebrow="Coming in phase 2" title="Player retention">
<template #title-append>
<Badge tone="neutral">Phase 2</Badge>
</template>
<div class="analytics-view__retention-grid">
<div class="analytics-view__retention-cell">
<p class="analytics-view__retention-label">New players</p>
<p class="analytics-view__retention-value"></p>
<p class="analytics-view__retention-note">First-time visitors</p>
</div>
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
<div class="analytics-view__retention-cell">
<p class="analytics-view__retention-label">Returning players</p>
<p class="analytics-view__retention-value"></p>
<p class="analytics-view__retention-note">Seen more than once</p>
</div>
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
<div class="analytics-view__retention-cell">
<p class="analytics-view__retention-label">Avg session duration</p>
<p class="analytics-view__retention-value"></p>
<p class="analytics-view__retention-note">Per visit</p>
</div>
</div>
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
</div>
<p class="analytics-view__retention-footer">
Player retention analytics will be available in phase 2.
</p>
</Panel>
</template>
</div>
</template>
<style scoped>
.analytics-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.analytics-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.analytics-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.analytics-view__controls {
display: flex;
align-items: center;
gap: 10px;
}
.analytics-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.analytics-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.analytics-view__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 1024px) {
.analytics-view__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.analytics-view__charts {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 1024px) {
.analytics-view__charts {
grid-template-columns: 1fr 1fr;
}
}
.analytics-view__chart-area {
height: 256px;
}
.analytics-view__retention-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 1024px) {
.analytics-view__retention-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.analytics-view__retention-cell {
background: var(--surface-raised);
border-radius: var(--radius-md);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-view__retention-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.analytics-view__retention-value {
font-family: var(--font-mono);
font-size: 24px;
font-weight: 600;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.analytics-view__retention-note {
font-size: var(--text-xs);
color: var(--text-muted);
}
.analytics-view__retention-footer {
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
margin-top: 12px;
}
</style>

View File

@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { ChatMessage } from '@/types'
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
const toast = useToastStore()
@@ -32,12 +39,18 @@ const filteredMessages = computed(() => {
return result
})
function channelBadgeClass(channel: string): string {
const channelTabItems = computed(() => [
{ value: 'all', label: 'All', count: messages.value.length },
{ value: 'global', label: 'Global' },
{ value: 'team', label: 'Team' },
{ value: 'server', label: 'Server' },
])
function channelTone(channel: string): 'accent' | 'info' | 'neutral' {
switch (channel) {
case 'global': return 'bg-oxide-500/15 text-oxide-400'
case 'team': return 'bg-blue-500/15 text-blue-400'
case 'server': return 'bg-neutral-700/50 text-neutral-400'
default: return 'bg-neutral-700/50 text-neutral-400'
case 'global': return 'accent'
case 'team': return 'info'
default: return 'neutral'
}
}
@@ -76,89 +89,163 @@ 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">
<MessageSquare class="w-5 h-5 text-oxide-500" />
<div class="clv">
<!-- Page head -->
<div class="clv__head">
<div class="clv__head-id">
<div class="clv__head-chip">
<Icon name="message-square" :size="20" :stroke-width="2" />
</div>
<div>
<h1 class="text-2xl font-bold text-neutral-100">Chat Log</h1>
<p class="text-sm text-neutral-500 mt-0.5">{{ messages.length }} messages</p>
<div class="t-eyebrow">Chat log</div>
<h1 class="clv__title">Chat log</h1>
</div>
</div>
<button
@click="fetchMessages"
:disabled="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': isLoading }" />
Refresh
</button>
<div class="clv__head-actions">
<div class="clv__stat-pill">
<span class="clv__stat-num">{{ messages.length }}</span>
<span class="clv__stat-label">messages</span>
</div>
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="fetchMessages"
>Refresh</Button>
</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 messages, players, or Steam IDs..."
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="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['all', 'global', 'team', 'server'] as const)"
:key="opt"
@click="channelFilter = opt"
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
:class="channelFilter === opt
? 'bg-oxide-500/15 text-oxide-400'
: 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
<div class="clv__filters">
<Input
v-model="searchQuery"
icon="search"
placeholder="Search messages, players, or Steam IDs…"
size="sm"
style="max-width: 340px;"
/>
<Tabs v-model="channelFilter" :items="channelTabItems" />
</div>
<!-- Messages -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg divide-y divide-neutral-800">
<div v-if="filteredMessages.length === 0" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading chat messages...</template>
<template v-else-if="searchQuery">No messages matching "{{ searchQuery }}"</template>
<template v-else>No chat messages yet. Messages will appear when the server is active.</template>
<!-- Messages panel -->
<Panel :flush-body="true">
<!-- Empty state -->
<EmptyState
v-if="filteredMessages.length === 0 && !isLoading"
icon="message-square"
:title="searchQuery ? 'No messages found' : 'No chat messages'"
:description="searchQuery
? `No messages matching &quot;${searchQuery}&quot;.`
: 'No chat messages yet. Messages will appear when the server is active.'"
/>
<!-- Loading -->
<div v-else-if="isLoading && filteredMessages.length === 0" class="clv__loading">
<Icon name="loader" :size="20" class="clv__spin" />
<span>Loading chat messages</span>
</div>
<div
v-for="msg in filteredMessages"
:key="msg.id"
class="flex items-start gap-4 px-4 py-3 hover:bg-neutral-800/50 transition-colors"
:class="{ 'bg-red-500/5 border-l-2 border-l-red-500/30': msg.flagged }"
>
<div class="shrink-0 text-right w-20">
<p class="text-xs text-neutral-500">{{ formatDate(msg.created_at) }}</p>
<p class="text-xs text-neutral-600">{{ formatTime(msg.created_at) }}</p>
</div>
<span
class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full mt-0.5"
:class="channelBadgeClass(msg.channel)"
<!-- Message list -->
<div v-else class="clv__messages">
<div
v-for="msg in filteredMessages"
:key="msg.id"
class="clv__row"
:class="{ 'clv__row--flagged': msg.flagged }"
>
{{ msg.channel }}
</span>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-oxide-400">{{ msg.player_name }}</span>
<span class="text-sm text-neutral-500 ml-2 font-mono">{{ msg.steam_id }}</span>
<p class="text-sm text-neutral-300 mt-0.5 break-words">{{ msg.message }}</p>
<!-- Timestamp -->
<div class="clv__ts">
<span class="clv__date">{{ formatDate(msg.created_at) }}</span>
<span class="clv__time">{{ formatTime(msg.created_at) }}</span>
</div>
<!-- Channel badge -->
<Badge :tone="channelTone(msg.channel)" size="md">{{ msg.channel }}</Badge>
<!-- Message body -->
<div class="clv__body">
<span class="clv__player">{{ msg.player_name }}</span>
<span class="clv__steam">{{ msg.steam_id }}</span>
<p class="clv__text">{{ msg.message }}</p>
</div>
<!-- Flag toggle -->
<IconButton
icon="bookmark"
:variant="msg.flagged ? 'accent' : 'ghost'"
size="sm"
:label="msg.flagged ? 'Unflag message' : 'Flag message'"
@click="toggleFlag(msg)"
/>
</div>
<button
@click="toggleFlag(msg)"
class="shrink-0 p-1 rounded transition-colors"
:class="msg.flagged ? 'text-red-400 hover:text-red-300' : 'text-neutral-600 hover:text-neutral-400'"
:title="msg.flagged ? 'Unflag message' : 'Flag message'"
>
<Flag class="w-4 h-4" />
</button>
</div>
</div>
</Panel>
</div>
</template>
<style scoped>
.clv { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
/* Page head */
.clv__head {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.clv__head-id { display: flex; align-items: center; gap: 12px; }
.clv__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);
}
.clv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
.clv__head-actions { display: flex; align-items: center; gap: 12px; }
/* Stat pill */
.clv__stat-pill { display: flex; align-items: center; gap: 6px; }
.clv__stat-num { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.clv__stat-label { font-size: var(--text-xs); color: var(--text-tertiary); }
/* Filters */
.clv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Loading */
.clv__loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
}
@keyframes clv-spin { to { transform: rotate(360deg); } }
.clv__spin { animation: clv-spin 0.7s linear infinite; }
/* Messages */
.clv__messages { display: flex; flex-direction: column; }
.clv__row {
display: flex; align-items: flex-start; gap: 14px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
transition: var(--transition-colors);
}
.clv__row:last-child { border-bottom: 0; }
.clv__row:hover { background: var(--surface-hover); }
.clv__row--flagged {
background: var(--status-offline-soft);
border-left: 3px solid var(--status-offline-border);
}
.clv__row--flagged:hover { background: var(--status-offline-soft); filter: brightness(1.04); }
/* Timestamp */
.clv__ts { display: flex; flex-direction: column; align-items: flex-end; min-width: 68px; flex: none; padding-top: 1px; }
.clv__date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
.clv__time { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
/* Message body */
.clv__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.clv__player { font-size: var(--text-sm); font-weight: 600; color: var(--accent-text); }
.clv__steam { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-left: 8px; }
.clv__text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; word-break: break-word; margin: 0; }
</style>

View File

@@ -1,11 +1,17 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import type { MapAnalyticsSummary } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
@@ -30,6 +36,10 @@ const loadMapAnalytics = async () => {
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!analytics.value || analytics.value.maps.length === 0) return
@@ -44,20 +54,29 @@ const renderCharts = () => {
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
performanceChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['Avg Players', 'Peak Players'],
textStyle: { color: '#a3a3a3' },
textStyle: { color: labelColor, fontFamily: mono },
top: 0
},
grid: {
@@ -70,22 +89,22 @@ const renderCharts = () => {
xAxis: {
type: 'category',
data: mapNames,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Players',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
series: [
{
name: 'Avg Players',
type: 'bar',
data: avgPlayers,
itemStyle: { color: '#CE422B' },
itemStyle: { color: accent },
barGap: '10%'
},
{
@@ -138,182 +157,263 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<div class="map-analytics-view">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Map class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="downloadCSV"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<Download class="w-4 h-4" />
<div class="map-analytics-view__header">
<h1 class="map-analytics-view__title">Map analytics</h1>
<div class="map-analytics-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['30d', '90d', 'all'] as const)"
:key="opt"
@click="timeRange = opt"
class="px-3 py-2 text-sm font-medium transition-colors"
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
</Button>
<Tabs
:items="[
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
{ value: 'all', label: 'All' }
]"
v-model="timeRange"
variant="pill"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading map analytics...</div>
<div v-if="loading" class="map-analytics-view__loading">
<span class="map-analytics-view__loading-text">Loading map analytics...</span>
</div>
<template v-else-if="analytics">
<!-- Summary cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Award class="w-4 h-4 text-oxide-400" />
<p class="text-sm text-neutral-400">Best Performing Map</p>
</div>
<p class="text-xl font-bold text-neutral-100">
{{ analytics.best_performing_map ?? 'No data' }}
</p>
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0">
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players
</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Target class="w-4 h-4 text-green-400" />
<p class="text-sm text-neutral-400">Rotation Effectiveness</p>
</div>
<p class="text-xl font-bold text-neutral-100">
{{ safeFixed(analytics?.rotation_effectiveness, 1) }}%
</p>
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-blue-400" />
<p class="text-sm text-neutral-400">Total Maps Tracked</p>
</div>
<p class="text-xl font-bold text-neutral-100">{{ analytics.maps.length }}</p>
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
</div>
<div class="map-analytics-view__stats">
<StatCard
label="Best performing map"
:value="analytics.best_performing_map ?? 'No data'"
icon="award"
:note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
/>
<StatCard
label="Rotation effectiveness"
:value="safeFixed(analytics?.rotation_effectiveness, 1)"
unit="%"
icon="target"
note="Overall rotation health"
/>
<StatCard
label="Total maps tracked"
:value="analytics.maps.length"
icon="trending-up"
:note="`Last ${timeRange}`"
/>
</div>
<!-- Performance chart -->
<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">
Map Performance Comparison
</h2>
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div>
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-600">No map data available for this time range</p>
</div>
</div>
<Panel title="Map performance comparison">
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
<EmptyState
v-else
icon="map"
title="No map data"
description="No map data available for this time range."
/>
</Panel>
<!-- Map performance table -->
<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">
Detailed Map Metrics
</h2>
<div v-if="sortedMaps.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-neutral-800">
<tr>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Avg Players</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Peak Players</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Effectiveness</th>
<Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
<div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
<table class="map-analytics-view__table">
<thead>
<tr class="map-analytics-view__thead-row">
<th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
<th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
<th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</th>
</tr>
</thead>
<tbody>
<tr
v-for="map in sortedMaps"
:key="map.map_id"
class="border-b border-neutral-800/50 hover:bg-neutral-800/30 transition-colors"
class="map-analytics-view__row"
>
<td class="py-3 px-4 text-neutral-200 font-medium">{{ map.map_name }}</td>
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ safeFixed(map.avg_players, 1) }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td>
<td class="py-3 px-4 text-right">
<span
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
:class="{
'bg-green-500/10 text-green-400': map.effectiveness_score >= 80,
'bg-yellow-500/10 text-yellow-400': map.effectiveness_score >= 60 && map.effectiveness_score < 80,
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
}"
<td class="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
<td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
<td class="map-analytics-view__td map-analytics-view__td--right">
<Badge
:tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
mono
>
{{ safeFixed(map.effectiveness_score, 1) }}%
</span>
</Badge>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-600">No map data available</p>
</div>
</div>
<EmptyState
v-else
icon="map"
title="No map data"
description="No map data available."
/>
</Panel>
<!-- Insights 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">
Actionable Insights
</h2>
<div class="space-y-3">
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg">
<Award class="w-5 h-5 text-oxide-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">
Your best map is <span class="text-oxide-400">{{ analytics.best_performing_map }}</span>
</p>
<p class="text-xs text-neutral-500 mt-1">
Consider featuring this map more frequently in your rotation for maximum player engagement.
</p>
</div>
</div>
<Panel title="Actionable insights">
<div class="map-analytics-view__insights">
<Alert
v-if="analytics.best_performing_map"
tone="accent"
:title="`Best map: ${analytics.best_performing_map}`"
>
Consider featuring this map more frequently in your rotation for maximum player engagement.
</Alert>
<div
<Alert
v-if="analytics.rotation_effectiveness < 70"
class="flex items-start gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"
tone="warn"
title="Rotation effectiveness is below optimal"
>
<Target class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">Rotation effectiveness is below optimal</p>
<p class="text-xs text-neutral-500 mt-1">
Consider removing low-performing maps (effectiveness &lt; 60%) and testing new maps to improve overall rotation health.
</p>
</div>
</div>
Consider removing low-performing maps (effectiveness &lt; 60%) and testing new maps to improve overall rotation health.
</Alert>
<div
<Alert
v-else-if="analytics.rotation_effectiveness >= 80"
class="flex items-start gap-3 p-3 bg-green-500/5 border border-green-500/20 rounded-lg"
tone="online"
title="Excellent rotation effectiveness"
>
<TrendingUp class="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">Excellent rotation effectiveness!</p>
<p class="text-xs text-neutral-500 mt-1">
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
</p>
</div>
</div>
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
</Alert>
</div>
</div>
</Panel>
</template>
</div>
</template>
<style scoped>
.map-analytics-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.map-analytics-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.map-analytics-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.map-analytics-view__controls {
display: flex;
align-items: center;
gap: 10px;
}
.map-analytics-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.map-analytics-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.map-analytics-view__stats {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 768px) {
.map-analytics-view__stats {
grid-template-columns: repeat(3, 1fr);
}
}
.map-analytics-view__chart-area {
height: 320px;
}
.map-analytics-view__table-wrap {
overflow-x: auto;
}
.map-analytics-view__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.map-analytics-view__thead-row {
border-bottom: 1px solid var(--border-subtle);
}
.map-analytics-view__th {
padding: 12px 16px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
}
.map-analytics-view__th--left {
text-align: left;
}
.map-analytics-view__th--right {
text-align: right;
}
.map-analytics-view__row {
border-bottom: 1px solid var(--border-subtle);
transition: background var(--dur-fast) var(--ease-standard);
}
.map-analytics-view__row:hover {
background: var(--surface-hover);
}
.map-analytics-view__td {
padding: 12px 16px;
color: var(--text-secondary);
}
.map-analytics-view__td--primary {
color: var(--text-primary);
font-weight: 500;
}
.map-analytics-view__td--num {
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.map-analytics-view__td--right {
text-align: right;
}
.map-analytics-view__insights {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>

View File

@@ -4,8 +4,12 @@ import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import type { MapEntry } from '@/types'
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { safeFileSize } 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 IconButton from '@/components/ds/core/IconButton.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const api = useApi()
const auth = useAuthStore()
@@ -20,8 +24,8 @@ function formatSize(bytes: number): string {
return safeFileSize(bytes)
}
function typeBadgeClass(type: string): string {
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400'
function mapTypeTone(type: string): 'accent' | 'info' {
return type === 'custom' ? 'accent' : 'info'
}
async function fetchMaps() {
@@ -92,84 +96,211 @@ 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">
<Map class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1>
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p>
</div>
<div class="maps">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Operations</div>
<h1 class="page__title">
Map library
<span v-if="maps.length > 0" class="page__count">{{ maps.length }} maps</span>
</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="fetchMaps"
<div class="page__actions">
<IconButton
icon="refresh-cw"
label="Refresh"
:class="isLoading && 'spin'"
:disabled="isLoading"
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</button>
@click="fetchMaps"
/>
<input
ref="fileInputRef"
type="file"
accept=".map"
class="hidden"
class="hidden-input"
@change="handleFileSelected"
/>
<button
<Button
icon="upload"
:loading="isUploading"
@click="triggerFileInput"
:disabled="isUploading"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
<Upload v-else class="w-4 h-4" />
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
</button>
{{ isUploading ? 'Uploading…' : 'Upload map' }}
</Button>
</div>
</div>
<!-- Maps grid -->
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Map class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Maps</h3>
<p class="text-sm text-neutral-500">Upload custom maps or they'll appear here when procedural maps are generated.</p>
</div>
<!-- Empty state -->
<Panel v-if="maps.length === 0">
<EmptyState
icon="map"
title="No maps"
description="Upload custom maps or they will appear here when procedural maps are generated."
>
<template #action>
<Button icon="upload" size="sm" @click="triggerFileInput">Upload map</Button>
</template>
</EmptyState>
</Panel>
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Maps grid -->
<div v-else class="maps-grid">
<div
v-for="map in maps"
:key="map.id"
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-neutral-700 transition-colors"
class="map-card"
>
<!-- Thumbnail or placeholder -->
<div class="h-32 bg-neutral-800 flex items-center justify-center">
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" />
<Map v-else class="w-8 h-8 text-neutral-600" />
<!-- Thumbnail -->
<div class="map-card__thumb">
<img
v-if="map.thumbnail_path"
:src="map.thumbnail_path"
:alt="map.display_name"
class="map-card__img"
/>
<svg v-else class="map-card__placeholder" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" /><line x1="9" y1="3" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="21" />
</svg>
</div>
<div class="p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3>
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)">
{{ map.map_type }}
</span>
<!-- Card body -->
<div class="map-card__body">
<div class="map-card__row">
<span class="map-card__name">{{ map.display_name }}</span>
<Badge :tone="mapTypeTone(map.map_type)" size="md">{{ map.map_type }}</Badge>
</div>
<div class="flex items-center justify-between text-xs text-neutral-500">
<div class="map-card__meta">
<span>{{ formatSize(map.file_size_bytes) }}</span>
<span v-if="map.world_size">{{ map.world_size }}m</span>
<span v-if="map.seed">Seed: {{ map.seed }}</span>
<span v-if="map.seed" class="mono">Seed: {{ map.seed }}</span>
</div>
<div class="flex justify-end mt-3">
<button
<div class="map-card__foot">
<IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Delete map"
@click="deleteMap(map)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete map"
>
<Trash2 class="w-4 h-4" />
</button>
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.maps {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Page head */
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page__title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
margin-top: 4px;
display: flex;
align-items: baseline;
gap: 10px;
}
.page__count {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-tertiary);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.page__actions {
display: flex;
align-items: center;
gap: 8px;
}
.hidden-input { display: none; }
/* Spin utility */
.spin { animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Maps grid */
.maps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
/* Map card */
.map-card {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
overflow: hidden;
transition: box-shadow var(--dur-base) var(--ease-standard);
}
.map-card:hover {
box-shadow: var(--ring-default), 0 0 0 1px var(--border-default);
}
.map-card__thumb {
height: 128px;
background: var(--surface-inset);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.map-card__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.map-card__placeholder { color: var(--text-muted); }
.map-card__body { padding: 12px 14px; }
.map-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
}
.map-card__name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.map-card__meta {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-tertiary);
flex-wrap: wrap;
}
.mono { font-family: var(--font-mono); }
.map-card__foot {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
@media (max-width: 640px) {
.maps-grid { grid-template-columns: 1fr 1fr; }
}
</style>

View File

@@ -3,8 +3,17 @@ import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import type { Module } from '@/types'
import { ShoppingCart, Package, Search, Filter, X, Check, Download, AlertCircle } from 'lucide-vue-next'
import { safeFixed } 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 IconButton from '@/components/ds/core/IconButton.vue'
import Tabs from '@/components/ds/navigation/Tabs.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'
const api = useApi()
const auth = useAuthStore()
@@ -22,17 +31,22 @@ const isPurchasing = ref(false)
const purchaseError = ref('')
const categories = [
{ value: 'all', label: 'All Modules' },
{ value: 'all', label: 'All modules' },
{ value: 'loot', label: 'Loot' },
{ value: 'events', label: 'Events' },
{ value: 'economy', label: 'Economy' },
{ value: 'kits', label: 'Kits' },
{ value: 'admin', label: 'Admin Tools' },
{ value: 'admin', label: 'Admin tools' },
{ value: 'pvp', label: 'PVP' },
{ value: 'pve', label: 'PVE' },
{ value: 'building', label: 'Building' },
]
const tabItems = computed(() => [
{ value: 'catalog', label: 'Catalog', icon: 'package' },
{ value: 'my-modules', label: `My modules (${myModules.value.length})`, icon: 'download' },
])
const filteredModules = computed(() => {
let result = activeTab.value === 'catalog' ? modules.value : myModules.value
@@ -51,6 +65,21 @@ const filteredModules = computed(() => {
return result
})
type BadgeTone = 'info' | 'accent' | 'warn' | 'online' | 'neutral' | 'offline'
function categoryTone(category: string): BadgeTone {
const map: Record<string, BadgeTone> = {
loot: 'warn',
events: 'accent',
economy: 'online',
kits: 'info',
admin: 'accent',
pvp: 'offline',
pve: 'info',
building: 'warn',
}
return map[category] ?? 'neutral'
}
async function loadCatalog() {
isLoading.value = true
try {
@@ -94,10 +123,8 @@ async function confirmPurchase() {
})
if (response.payment_url) {
// Redirect to external payment provider
window.location.href = response.payment_url
} else if (response.success) {
// Instant purchase confirmed
showPurchaseModal.value = false
selectedModule.value = null
await loadCatalog()
@@ -131,20 +158,6 @@ function closeModals() {
purchaseError.value = ''
}
function categoryBadgeClass(category: string): string {
const colors: Record<string, string> = {
loot: 'bg-yellow-500/15 text-yellow-400',
events: 'bg-purple-500/15 text-purple-400',
economy: 'bg-green-500/15 text-green-400',
kits: 'bg-blue-500/15 text-blue-400',
admin: 'bg-oxide-500/15 text-oxide-400',
pvp: 'bg-red-500/15 text-red-400',
pve: 'bg-indigo-500/15 text-indigo-400',
building: 'bg-orange-500/15 text-orange-400',
}
return colors[category] || 'bg-neutral-700/50 text-neutral-400'
}
onMounted(() => {
loadCatalog()
loadMyModules()
@@ -152,323 +165,371 @@ 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">
<ShoppingCart class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Module Store</h1>
<p class="text-sm text-neutral-500 mt-0.5">
Extend your server with premium gameplay modules
</p>
</div>
</div>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
@click="activeTab = 'catalog'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'catalog' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
<span class="flex items-center gap-2">
<Package class="w-4 h-4" />
Catalog
</span>
</button>
<button
@click="activeTab = 'my-modules'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'my-modules' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
<span class="flex items-center gap-2">
<Download class="w-4 h-4" />
My Modules ({{ myModules.length }})
</span>
</button>
<div class="ms-page">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Management</div>
<h1 class="page__title">Module store</h1>
<p class="page__sub">Extend your server with premium gameplay modules.</p>
</div>
<Tabs v-model="activeTab" :items="tabItems" />
</div>
<!-- Filters -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<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 modules..."
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="flex items-center gap-2">
<Filter class="w-4 h-4 text-neutral-500" />
<select
v-model="selectedCategory"
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="cat in categories" :key="cat.value" :value="cat.value">
{{ cat.label }}
</option>
</select>
</div>
<!-- Filters bar -->
<div class="ms-filters">
<Input
v-model="searchQuery"
icon="search"
placeholder="Search modules…"
class="ms-filters__search"
/>
<Select
v-model="selectedCategory"
:options="categories"
size="md"
class="ms-filters__cat"
/>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading modules...</div>
<div v-if="isLoading" class="ms-loading">
<EmptyState icon="loader" title="Loading modules…" />
</div>
<!-- Module grid -->
<div v-else-if="filteredModules.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
<div v-else-if="filteredModules.length > 0" class="ms-grid">
<article
v-for="module in filteredModules"
:key="module.id"
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-oxide-500/30 transition-all group"
class="ms-card"
>
<!-- Preview Image -->
<div class="relative h-40 bg-neutral-800 overflow-hidden">
<!-- Preview image -->
<div class="ms-card__img">
<img
v-if="module.preview_image_url"
:src="module.preview_image_url"
:alt="module.name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
class="ms-card__img-el"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<Package class="w-12 h-12 text-neutral-700" />
<div v-else class="ms-card__img-placeholder">
<Icon name="package" :size="36" :stroke-width="1.5" />
</div>
<div class="absolute top-2 right-2">
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(module.category)">
{{ module.category }}
</span>
<div class="ms-card__img-badges">
<Badge :tone="categoryTone(module.category)" size="md">{{ module.category }}</Badge>
</div>
<div v-if="module.is_purchased" class="absolute top-2 left-2">
<span class="text-xs font-medium px-2 py-1 rounded-full bg-green-500/20 text-green-400 flex items-center gap-1">
<Check class="w-3 h-3" />
Purchased
</span>
<div v-if="module.is_purchased" class="ms-card__img-owned">
<Badge tone="online" icon="check" size="md">Purchased</Badge>
</div>
</div>
<!-- Content -->
<div class="p-4 space-y-3">
<div>
<div class="flex items-start justify-between gap-2 mb-1">
<h3 class="text-base font-semibold text-neutral-100">{{ module.name }}</h3>
<span class="text-lg font-bold text-oxide-400 shrink-0">${{ safeFixed(module.price, 2) }}</span>
</div>
<p class="text-sm text-neutral-500 line-clamp-2">{{ module.description }}</p>
<div class="ms-card__body">
<div class="ms-card__header">
<span class="ms-card__name">{{ module.name }}</span>
<span class="ms-price">${{ safeFixed(module.price, 2) }}</span>
</div>
<p class="ms-card__desc">{{ module.description }}</p>
<!-- Features -->
<div class="flex flex-wrap gap-1.5">
<span
<div class="ms-card__features">
<Badge
v-for="(feature, idx) in module.features.slice(0, 3)"
:key="idx"
class="text-xs text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded"
>
{{ feature }}
</span>
<span v-if="module.features.length > 3" class="text-xs text-neutral-500">
+{{ module.features.length - 3 }} more
</span>
tone="neutral"
>{{ feature }}</Badge>
<span v-if="module.features.length > 3" class="ms-card__more">+{{ module.features.length - 3 }} more</span>
</div>
<!-- Actions -->
<div class="flex gap-2 pt-2">
<button
@click="openDetailModal(module)"
class="flex-1 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
>
Details
</button>
<button
<div class="ms-card__actions">
<Button variant="secondary" size="sm" :block="false" @click="openDetailModal(module)">Details</Button>
<Button
v-if="!module.is_purchased"
size="sm"
@click="initiatePurchase(module)"
class="flex-1 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
Purchase
</button>
<button
>Purchase</Button>
<Button
v-else-if="!module.is_installed"
size="sm"
variant="outline"
icon="download"
@click="installModule(module)"
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
>
<Download class="w-3.5 h-3.5" />
Install
</button>
<button
>Install</Button>
<Button
v-else
disabled
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 border border-green-500/20 rounded-lg cursor-default"
>
<Check class="w-3.5 h-3.5" />
Installed
</button>
size="sm"
variant="outline"
icon="check"
:disabled="true"
>Installed</Button>
</div>
</div>
</div>
</article>
</div>
<!-- Empty state -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">
{{ activeTab === 'catalog' ? 'No modules found' : 'No purchased modules' }}
</h3>
<p class="text-sm text-neutral-500">
{{ activeTab === 'catalog' ? 'Try adjusting your filters.' : 'Browse the catalog to purchase modules.' }}
</p>
</div>
<Panel v-else>
<EmptyState
icon="package"
:title="activeTab === 'catalog' ? 'No modules found' : 'No purchased modules'"
:description="activeTab === 'catalog' ? 'Try adjusting your search or category filter.' : 'Browse the catalog to purchase modules.'"
/>
</Panel>
<!-- Detail Modal -->
<!-- ===== Detail modal ===== -->
<div
v-if="showDetailModal && selectedModule"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="ms-modal-backdrop"
@click.self="closeModals"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<!-- Modal Header -->
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h2 class="text-xl font-bold text-neutral-100">{{ selectedModule.name }}</h2>
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(selectedModule.category)">
{{ selectedModule.category }}
</span>
<div class="ms-modal ms-modal--wide">
<div class="ms-modal__head ms-modal__head--sticky">
<div class="ms-modal__head-content">
<div class="ms-modal__title-row">
<h2 class="ms-modal__title">{{ selectedModule.name }}</h2>
<Badge :tone="categoryTone(selectedModule.category)" size="md">{{ selectedModule.category }}</Badge>
</div>
<p class="text-sm text-neutral-400">Version {{ selectedModule.version }}</p>
<p class="ms-modal__ver">Version {{ selectedModule.version }}</p>
</div>
<button
@click="closeModals"
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
>
<X class="w-5 h-5" />
</button>
<IconButton icon="x" label="Close" @click="closeModals" />
</div>
<!-- Modal Body -->
<div class="p-6 space-y-6">
<div class="ms-modal__body">
<!-- Screenshots -->
<div v-if="selectedModule.screenshots.length > 0" class="space-y-3">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Screenshots</h3>
<div class="grid grid-cols-2 gap-3">
<div v-if="selectedModule.screenshots.length > 0" class="ms-detail-section">
<div class="ms-detail-section__label">Screenshots</div>
<div class="ms-screenshots">
<img
v-for="(screenshot, idx) in selectedModule.screenshots"
:key="idx"
:src="screenshot"
:alt="`Screenshot ${idx + 1}`"
class="w-full h-48 object-cover rounded-lg border border-neutral-800"
class="ms-screenshot"
/>
</div>
</div>
<!-- Description -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Description</h3>
<p class="text-sm text-neutral-400 leading-relaxed">{{ selectedModule.description }}</p>
<div class="ms-detail-section">
<div class="ms-detail-section__label">Description</div>
<p class="ms-detail-desc">{{ selectedModule.description }}</p>
</div>
<!-- Features -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Features</h3>
<ul class="space-y-2">
<li
v-for="(feature, idx) in selectedModule.features"
:key="idx"
class="flex items-start gap-2 text-sm text-neutral-400"
>
<Check class="w-4 h-4 text-oxide-500 shrink-0 mt-0.5" />
<div class="ms-detail-section">
<div class="ms-detail-section__label">Features</div>
<ul class="ms-features">
<li v-for="(feature, idx) in selectedModule.features" :key="idx" class="ms-feature">
<Icon name="check" :size="14" :stroke-width="2.5" class="ms-feature__icon" />
{{ feature }}
</li>
</ul>
</div>
<!-- Price and Purchase -->
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-center justify-between">
<!-- Price and action -->
<div class="ms-detail-cta">
<div>
<p class="text-sm text-neutral-400 mb-1">One-time purchase</p>
<p class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</p>
<p class="ms-detail-cta__label">One-time purchase</p>
<p class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</p>
</div>
<button
<Button
v-if="!selectedModule.is_purchased"
@click="initiatePurchase(selectedModule)"
class="px-6 py-3 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
Purchase Now
</button>
<button
>Purchase now</Button>
<Button
v-else-if="!selectedModule.is_installed"
variant="outline"
icon="download"
@click="installModule(selectedModule)"
class="flex items-center gap-2 px-6 py-3 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
>
<Download class="w-4 h-4" />
Install
</button>
<div v-else class="flex items-center gap-2 text-green-400">
<Check class="w-5 h-5" />
<span class="text-sm font-medium">Installed</span>
>Install</Button>
<div v-else class="ms-installed">
<Icon name="check" :size="18" :stroke-width="2.5" />
<span>Installed</span>
</div>
</div>
</div>
</div>
</div>
<!-- Purchase Confirmation Modal -->
<!-- ===== Purchase confirmation modal ===== -->
<div
v-if="showPurchaseModal && selectedModule"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="ms-modal-backdrop"
@click.self="closeModals"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-md w-full">
<!-- Header -->
<div class="border-b border-neutral-800 px-6 py-4">
<h2 class="text-xl font-bold text-neutral-100">Confirm Purchase</h2>
<div class="ms-modal">
<div class="ms-modal__head">
<h2 class="ms-modal__title">Confirm purchase</h2>
<IconButton icon="x" label="Close" @click="closeModals" />
</div>
<!-- Body -->
<div class="p-6 space-y-4">
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-neutral-400">Module</span>
<span class="text-sm font-medium text-neutral-100">{{ selectedModule.name }}</span>
<div class="ms-modal__body">
<div class="ms-purchase-summary">
<div class="ms-purchase-row">
<span class="ms-purchase-row__label">Module</span>
<span class="ms-purchase-row__value">{{ selectedModule.name }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-neutral-400">License</span>
<span class="text-sm font-medium text-neutral-100">{{ auth.license?.license_key }}</span>
<div class="ms-purchase-row">
<span class="ms-purchase-row__label">License</span>
<span class="ms-purchase-row__value ms-mono">{{ auth.license?.license_key }}</span>
</div>
<div class="border-t border-neutral-700 pt-2 mt-2 flex items-center justify-between">
<span class="text-base font-medium text-neutral-300">Total</span>
<span class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</span>
<div class="ms-purchase-divider" />
<div class="ms-purchase-row ms-purchase-row--total">
<span class="ms-purchase-row__label">Total</span>
<span class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</span>
</div>
</div>
<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>
<Alert v-if="purchaseError" tone="danger">{{ purchaseError }}</Alert>
<p class="text-xs text-neutral-500 leading-relaxed">
<p class="ms-purchase-terms">
By confirming this purchase, you agree to the module license terms. This purchase is non-refundable once the module is installed.
</p>
</div>
<!-- Footer -->
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeModals"
:disabled="isPurchasing"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
@click="confirmPurchase"
:disabled="isPurchasing"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors disabled:opacity-50"
>
{{ isPurchasing ? 'Processing...' : 'Confirm Purchase' }}
</button>
<div class="ms-modal__foot">
<Button variant="secondary" :disabled="isPurchasing" @click="closeModals">Cancel</Button>
<Button :loading="isPurchasing" @click="confirmPurchase">Confirm purchase</Button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ms-page { max-width: 1320px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.page__head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; flex-wrap: wrap; row-gap: 12px;
}
.page__title {
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 5px;
}
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
/* Filters */
.ms-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.ms-filters__search { flex: 1; min-width: 200px; max-width: 380px; }
.ms-filters__cat { min-width: 160px; }
.ms-loading { padding: 20px 0; }
/* Module grid */
.ms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
/* Module card */
.ms-card {
background: var(--surface-base); border-radius: var(--radius-lg);
box-shadow: var(--ring-default); overflow: hidden; display: flex; flex-direction: column;
transition: box-shadow var(--dur-base) var(--ease-standard);
}
.ms-card:hover { box-shadow: inset 0 0 0 1px var(--accent-border), var(--ring-default); }
.ms-card__img {
position: relative; height: 160px; overflow: hidden;
background: var(--surface-inset);
}
.ms-card__img-el {
width: 100%; height: 100%; object-fit: cover;
transition: transform 300ms var(--ease-standard);
}
.ms-card:hover .ms-card__img-el { transform: scale(1.04); }
.ms-card__img-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-muted);
}
.ms-card__img-badges { position: absolute; top: 8px; right: 8px; }
.ms-card__img-owned { position: absolute; top: 8px; left: 8px; }
.ms-card__body { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.ms-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.ms-card__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.ms-card__desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ms-card__features { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.ms-card__more { font-size: var(--text-xs); color: var(--text-muted); }
.ms-card__actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
/* Price — mono + tabular */
.ms-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; font-size: var(--text-base); color: var(--accent-text); }
.ms-price--lg { font-size: var(--text-2xl); }
.ms-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
/* Modal */
.ms-modal-backdrop {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
}
.ms-modal {
background: var(--surface-base); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 480px; width: 100%;
display: flex; flex-direction: column; max-height: 90vh;
}
.ms-modal--wide { max-width: 760px; }
.ms-modal__head {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); flex: none;
}
.ms-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
.ms-modal__head-content { flex: 1; min-width: 0; }
.ms-modal__title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ms-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
.ms-modal__ver { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 3px; }
.ms-modal__body { padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
.ms-modal__foot {
display: flex; align-items: center; justify-content: flex-end;
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex: none;
}
/* Detail modal content */
.ms-detail-section { display: flex; flex-direction: column; gap: 10px; }
.ms-detail-section__label {
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
}
.ms-detail-desc { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
.ms-screenshots { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ms-screenshot { width: 100%; height: 180px; object-fit: cover; border-radius: var(--radius-md); box-shadow: var(--ring-default); }
.ms-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.ms-feature { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-sm); color: var(--text-secondary); }
.ms-feature__icon { flex: none; color: var(--accent-text); margin-top: 2px; }
.ms-detail-cta {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 14px 16px;
}
.ms-detail-cta__label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
.ms-installed {
display: flex; align-items: center; gap: 8px;
font-size: var(--text-sm); font-weight: 500; color: var(--status-online);
}
/* Purchase summary */
.ms-purchase-summary {
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 14px 16px;
display: flex; flex-direction: column; gap: 10px;
}
.ms-purchase-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ms-purchase-row--total { margin-top: 2px; }
.ms-purchase-row__label { font-size: var(--text-sm); color: var(--text-tertiary); }
.ms-purchase-row__value { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.ms-purchase-divider { height: 1px; background: var(--border-subtle); }
.ms-purchase-terms { font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; }
/* Responsive */
@media (max-width: 768px) {
.ms-grid { grid-template-columns: 1fr; }
.ms-filters__search { max-width: 100%; }
}
</style>

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Select from '@/components/ds/forms/Select.vue'
const api = useApi()
const authStore = useAuthStore()
@@ -60,6 +64,10 @@ const loadRetentionData = async () => {
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
@@ -81,13 +89,22 @@ const renderCharts = () => {
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
retentionChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => {
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
params.forEach((param: any) => {
@@ -98,7 +115,7 @@ const renderCharts = () => {
},
legend: {
data: ['24h Return', '48h Return', '72h Return'],
textStyle: { color: '#a3a3a3' },
textStyle: { color: labelColor, fontFamily: mono },
top: 0
},
grid: {
@@ -111,16 +128,18 @@ const renderCharts = () => {
xAxis: {
type: 'category',
data: wipeLabels,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Retention %',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: {
color: '#808080',
color: labelColor,
fontFamily: mono,
fontSize: 10,
formatter: (value: number) => `${value}%`
},
max: 100
@@ -131,8 +150,8 @@ const renderCharts = () => {
type: 'line',
data: retention24h,
smooth: true,
lineStyle: { color: '#CE422B', width: 3 },
itemStyle: { color: '#CE422B' },
lineStyle: { color: accent, width: 3 },
itemStyle: { color: accent },
symbolSize: 8
},
{
@@ -187,117 +206,91 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<div class="retention-view">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Users class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Player Retention</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="downloadCSV"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<Download class="w-4 h-4" />
<div class="retention-view__header">
<h1 class="retention-view__title">Player retention</h1>
<div class="retention-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</button>
<div class="flex items-center gap-2 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2">
<label class="text-sm text-neutral-400">Wipes:</label>
<select
v-model.number="wipeCount"
class="bg-transparent text-neutral-100 text-sm focus:outline-none"
>
<option :value="3">Last 3</option>
<option :value="6">Last 6</option>
<option :value="10">Last 10</option>
<option :value="20">Last 20</option>
</select>
</div>
</Button>
<Select
:options="[
{ value: '3', label: 'Last 3 wipes' },
{ value: '6', label: 'Last 6 wipes' },
{ value: '10', label: 'Last 10 wipes' },
{ value: '20', label: 'Last 20 wipes' }
]"
:model-value="String(wipeCount)"
size="sm"
@update:model-value="wipeCount = Number($event)"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading retention data...</div>
<div v-if="loading" class="retention-view__loading">
<span class="retention-view__loading-text">Loading retention data...</span>
</div>
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
<!-- Summary cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Users class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Unique Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.unique_players }}</p>
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Avg Session</p>
</div>
<p class="text-2xl font-bold text-neutral-100">
{{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m
</p>
<p class="text-xs text-neutral-600 mt-1">Duration</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">New Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.new_players }}</p>
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Returning</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.returning_players }}</p>
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
</div>
<div class="retention-view__stats">
<StatCard
label="Unique players"
:value="retentionData.summary.unique_players"
icon="users"
note="Last 30 days"
/>
<StatCard
label="Avg session"
:value="safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0)"
unit="m"
icon="clock"
note="Duration"
/>
<StatCard
label="New players"
:value="retentionData.summary.new_players"
icon="trending-up"
note="Last 30 days"
/>
<StatCard
label="Returning"
:value="retentionData.summary.returning_players"
icon="bar-chart-3"
note="Last 30 days"
/>
</div>
<!-- Retention curve chart -->
<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">
Retention Curve (Post-Wipe Return Rates)
</h2>
<div ref="retentionChart" class="h-96"></div>
<div class="mt-4 text-xs text-neutral-500">
<p>
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and
returned within 24h/48h/72h after the wipe.
</p>
</div>
</div>
<Panel title="Retention curve (post-wipe return rates)">
<div ref="retentionChart" class="retention-view__chart-area"></div>
<p class="retention-view__chart-note">
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and returned within 24h/48h/72h after the wipe.
</p>
</Panel>
<!-- Wipe details table -->
<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">
Wipe Details
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<Panel title="Wipe details" :flush-body="true">
<div class="retention-view__table-wrap">
<table class="retention-view__table">
<thead>
<tr class="border-b border-neutral-800">
<th class="text-left py-2 px-3 text-neutral-400 font-medium">Wipe Date</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">Pre-Wipe Players</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">24h Return</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">48h Return</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">72h Return</th>
<tr class="retention-view__thead-row">
<th class="retention-view__th retention-view__th--left">Wipe date</th>
<th class="retention-view__th retention-view__th--right">Pre-wipe players</th>
<th class="retention-view__th retention-view__th--right">24h return</th>
<th class="retention-view__th retention-view__th--right">48h return</th>
<th class="retention-view__th retention-view__th--right">72h return</th>
</tr>
</thead>
<tbody>
<tr
v-for="wipe in retentionData.wipe_metrics"
:key="wipe.wipe_id"
class="border-b border-neutral-800 hover:bg-neutral-800/50 transition-colors"
class="retention-view__row"
>
<td class="py-3 px-3 text-neutral-300">
<td class="retention-view__td retention-view__td--date">
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
@@ -306,38 +299,165 @@ onMounted(() => {
minute: '2-digit'
}) }}
</td>
<td class="py-3 px-3 text-right text-neutral-300">
<td class="retention-view__td retention-view__td--num">
{{ wipe.total_players_before_wipe }}
</td>
<td class="py-3 px-3 text-right">
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_24h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
</td>
<td class="py-3 px-3 text-right">
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_48h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
</td>
<td class="py-3 px-3 text-right">
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_72h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Panel>
</template>
<!-- Empty state -->
<div
v-else
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
>
<Users class="w-12 h-12 text-neutral-700 mx-auto mb-4" />
<p class="text-neutral-500 mb-2">No retention data available</p>
<p class="text-sm text-neutral-600">
Player retention metrics will appear here after wipes are tracked and players join/leave.
</p>
</div>
<Panel v-else>
<EmptyState
icon="users"
title="No retention data available"
description="Player retention metrics will appear here after wipes are tracked and players join/leave."
/>
</Panel>
</div>
</template>
<style scoped>
.retention-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.retention-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.retention-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.retention-view__controls {
display: flex;
align-items: center;
gap: 10px;
}
.retention-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.retention-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.retention-view__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 1024px) {
.retention-view__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.retention-view__chart-area {
height: 384px;
}
.retention-view__chart-note {
margin-top: 12px;
font-size: var(--text-xs);
color: var(--text-tertiary);
line-height: 1.5;
}
.retention-view__table-wrap {
overflow-x: auto;
}
.retention-view__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.retention-view__thead-row {
border-bottom: 1px solid var(--border-subtle);
}
.retention-view__th {
padding: 10px 12px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
}
.retention-view__th--left {
text-align: left;
}
.retention-view__th--right {
text-align: right;
}
.retention-view__row {
border-bottom: 1px solid var(--border-subtle);
transition: background var(--dur-fast) var(--ease-standard);
}
.retention-view__row:hover {
background: var(--surface-hover);
}
.retention-view__td {
padding: 12px 12px;
color: var(--text-secondary);
}
.retention-view__td--date {
color: var(--text-secondary);
}
.retention-view__td--num {
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.retention-view__count {
color: var(--text-primary);
font-weight: 500;
}
.retention-view__pct {
color: var(--text-muted);
font-size: var(--text-xs);
margin-left: 4px;
}
</style>

View File

@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const server = useServerStore()
const api = useApi()
@@ -50,6 +57,12 @@ const filteredPlayers = computed(() => {
const onlineCount = computed(() => players.value.filter(p => p.is_online).length)
const statusTabItems = computed(() => [
{ value: 'all', label: 'All', count: players.value.length },
{ value: 'online', label: 'Online', count: players.value.filter(p => p.is_online).length },
{ value: 'offline', label: 'Offline', count: players.value.filter(p => !p.is_online).length },
])
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
@@ -58,7 +71,7 @@ function formatPlaytime(seconds: number): string {
}
function formatConnectedTime(iso: string | null): string {
if (!iso) return '\u2014'
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
const h = Math.floor(m / 60)
@@ -66,6 +79,18 @@ function formatConnectedTime(iso: string | null): string {
return `${m}m`
}
function playerStatusTone(player: Player): 'online' | 'offline' | 'warn' {
if (player.is_banned) return 'warn'
if (player.is_online) return 'online'
return 'offline'
}
function playerStatusLabel(player: Player): string {
if (player.is_banned) return 'Banned'
if (player.is_online) return 'Online'
return 'Offline'
}
async function fetchPlayers() {
isLoading.value = true
try {
@@ -106,132 +131,197 @@ 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">
<Users class="w-5 h-5 text-oxide-500" />
<div class="pv">
<!-- Page head -->
<div class="pv__head">
<div class="pv__head-id">
<div class="pv__head-chip">
<Icon name="users" :size="20" :stroke-width="2" />
</div>
<div>
<h1 class="text-2xl font-bold text-neutral-100">Player Management</h1>
<p class="text-sm text-neutral-500 mt-0.5">
{{ onlineCount }} online / {{ players.length }} total
</p>
<div class="t-eyebrow">Player management</div>
<h1 class="pv__title">Players</h1>
</div>
</div>
<button
@click="fetchPlayers"
:disabled="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': isLoading }" />
Refresh
</button>
<div class="pv__head-actions">
<div class="pv__stat-pill">
<span class="pv__stat-num">{{ onlineCount }}</span>
<span class="pv__stat-sep">/</span>
<span class="pv__stat-total">{{ players.length }}</span>
<Badge tone="online" :dot="true" :pulse="onlineCount > 0">Online</Badge>
</div>
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="fetchPlayers"
>Refresh</Button>
</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 name or Steam ID..."
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="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['all', 'online', 'offline'] as const)"
:key="opt"
@click="filterStatus = opt"
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
:class="filterStatus === opt
? 'bg-oxide-500/15 text-oxide-400'
: 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
<div class="pv__filters">
<Input
v-model="searchQuery"
icon="search"
placeholder="Search by name or Steam ID…"
size="sm"
:mono="false"
style="max-width: 320px;"
/>
<Tabs v-model="filterStatus" :items="statusTabItems" />
</div>
<!-- Player table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<Panel :flush-body="true">
<!-- Empty state -->
<EmptyState
v-if="filteredPlayers.length === 0 && !isLoading"
icon="users"
:title="searchQuery ? 'No players found' : 'No players'"
:description="searchQuery
? `No players matching &quot;${searchQuery}&quot;.`
: 'No players found. Server may be offline or API not connected.'"
/>
<!-- Loading state -->
<div v-else-if="isLoading && filteredPlayers.length === 0" class="pv__loading">
<Icon name="loader" :size="20" class="pv__spin" />
<span>Loading players</span>
</div>
<!-- Table -->
<table v-else class="pv__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">Player</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Steam ID</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">Session</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Playtime</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Ping</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
<tr>
<th>Player</th>
<th>Steam ID</th>
<th>Status</th>
<th>Session</th>
<th>Playtime</th>
<th>Ping</th>
<th class="pv__th-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="filteredPlayers.length === 0">
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading players...</template>
<template v-else-if="searchQuery">No players matching "{{ searchQuery }}"</template>
<template v-else>No players found. Server may be offline or API not connected.</template>
</td>
</tr>
<tbody>
<tr
v-for="player in filteredPlayers"
:key="player.steam_id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-100">{{ player.display_name }}</span>
<Shield v-if="player.is_admin" class="w-3.5 h-3.5 text-oxide-400" title="Admin" />
<td>
<div class="pv__player-name">
<span class="pv__name">{{ player.display_name }}</span>
<Badge v-if="player.is_admin" tone="accent" size="md">Admin</Badge>
</div>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ player.steam_id }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full"
:class="player.is_online
? 'bg-green-500/10 text-green-400'
: player.is_banned
? 'bg-red-500/10 text-red-400'
: 'bg-neutral-700/50 text-neutral-400'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="player.is_online ? 'bg-green-500' : player.is_banned ? 'bg-red-500' : 'bg-neutral-500'"
/>
{{ player.is_banned ? 'Banned' : player.is_online ? 'Online' : 'Offline' }}
</span>
<td class="pv__mono">{{ player.steam_id }}</td>
<td>
<Badge
:tone="playerStatusTone(player)"
:dot="true"
:pulse="player.is_online && !player.is_banned"
>{{ playerStatusLabel(player) }}</Badge>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">
{{ player.is_online ? formatConnectedTime(player.connected_at) : '\u2014' }}
<td class="pv__secondary">
{{ player.is_online ? formatConnectedTime(player.connected_at) : '' }}
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatPlaytime(player.playtime_seconds) }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '\u2014' }}
<td class="pv__secondary pv__mono">{{ formatPlaytime(player.playtime_seconds) }}</td>
<td class="pv__secondary pv__mono">
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '' }}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1" v-if="player.is_online">
<button
<td class="pv__td-right">
<div v-if="player.is_online" class="pv__row-actions">
<IconButton
icon="log-out"
variant="ghost"
size="sm"
label="Kick"
@click="kickPlayer(player.steam_id, player.display_name)"
class="p-1.5 text-neutral-500 hover:text-yellow-400 rounded transition-colors"
title="Kick"
>
<LogOut class="w-4 h-4" />
</button>
<button
/>
<IconButton
icon="ban"
variant="danger"
size="sm"
label="Ban"
@click="banPlayer(player.steam_id, player.display_name)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Ban"
>
<Ban class="w-4 h-4" />
</button>
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
</div>
</template>
<style scoped>
.pv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
/* Page head */
.pv__head {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.pv__head-id { display: flex; align-items: center; gap: 12px; }
.pv__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);
}
.pv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
.pv__head-actions { display: flex; align-items: center; gap: 12px; }
/* Stat pill */
.pv__stat-pill {
display: flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
}
.pv__stat-num { font-weight: 700; color: var(--status-online); }
.pv__stat-sep { color: var(--text-muted); }
.pv__stat-total { color: var(--text-tertiary); }
/* Filters */
.pv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Loading */
.pv__loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
}
@keyframes pv-spin { to { transform: rotate(360deg); } }
.pv__spin { animation: pv-spin 0.7s linear infinite; }
/* Table */
.pv__table { width: 100%; border-collapse: collapse; }
.pv__table th {
padding: 10px 14px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.pv__table td {
padding: 10px 14px; font-size: var(--text-sm);
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
}
.pv__table tbody tr:last-child td { border-bottom: 0; }
.pv__table tbody tr:hover td { background: var(--surface-hover); }
/* Col helpers */
.pv__th-right { text-align: right; }
.pv__td-right { text-align: right; }
.pv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
.pv__secondary { color: var(--text-secondary) !important; }
/* Player name cell */
.pv__player-name { display: flex; align-items: center; gap: 8px; }
.pv__name { font-weight: 500; }
/* Row actions */
.pv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
</style>

View File

@@ -4,7 +4,15 @@ import { usePluginStore } from '@/stores/plugins'
import type { UmodPlugin } from '@/stores/plugins'
import { useToastStore } from '@/stores/toast'
import type { PluginEntry } from '@/types'
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const pluginStore = usePluginStore()
const toast = useToastStore()
@@ -35,6 +43,12 @@ const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
const tabItems = [
{ value: 'installed', label: 'Installed' },
{ value: 'browse', label: 'Browse uMod' },
{ value: 'upload', label: 'Upload custom' },
]
function sourceLabel(source: string): string {
switch (source) {
case 'umod': return 'uMod'
@@ -44,11 +58,11 @@ function sourceLabel(source: string): string {
}
}
function sourceBadgeClass(source: string): string {
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
switch (source) {
case 'umod': return 'bg-green-500/10 text-green-400'
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400'
default: return 'bg-neutral-700/50 text-neutral-400'
case 'umod': return 'online'
case 'corrosion_module': return 'accent'
default: return 'neutral'
}
}
@@ -168,274 +182,249 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Puzzle class="w-5 h-5 text-oxide-500" />
<div class="plv">
<!-- Page head -->
<div class="plv__head">
<div class="plv__head-id">
<div class="plv__head-chip">
<Icon name="puzzle" :size="20" :stroke-width="2" />
</div>
<div>
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1>
<p class="text-sm text-neutral-500 mt-0.5">
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
</p>
<div class="t-eyebrow">Plugin management</div>
<h1 class="plv__title">Plugins</h1>
</div>
</div>
<button
@click="pluginStore.fetchPlugins()"
:disabled="pluginStore.isLoading"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" />
Refresh
</button>
</div>
<!-- Tabs + Search -->
<div class="flex items-center gap-4">
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
@click="tab = 'installed'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Installed
</button>
<button
@click="tab = 'browse'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'browse' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Browse uMod
</button>
<button
@click="tab = 'upload'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Upload Custom
</button>
</div>
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery"
type="text"
placeholder="Search installed plugins..."
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="browseQuery"
type="text"
placeholder="Search uMod plugins..."
@input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)"
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
<div class="plv__head-actions">
<div class="plv__stat-pill">
<span class="plv__stat-num">{{ loadedCount }}</span>
<span class="plv__stat-sep">/</span>
<span class="plv__stat-total">{{ pluginStore.plugins.length }}</span>
<span class="plv__stat-label">loaded</span>
</div>
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="pluginStore.isLoading"
:disabled="pluginStore.isLoading"
@click="pluginStore.fetchPlugins()"
>Refresh</Button>
</div>
</div>
<!-- Installed Plugins -->
<div v-if="tab === 'installed'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<!-- Tab bar + search -->
<div class="plv__toolbar">
<Tabs v-model="tab" :items="tabItems" />
<Input
v-if="tab === 'installed'"
v-model="searchQuery"
icon="search"
placeholder="Search installed plugins…"
size="sm"
style="max-width: 280px;"
/>
<Input
v-if="tab === 'browse'"
v-model="browseQuery"
icon="search"
placeholder="Search uMod plugins…"
size="sm"
style="max-width: 280px;"
@input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)"
/>
</div>
<!-- ===== INSTALLED TAB ===== -->
<Panel v-if="tab === 'installed'" :flush-body="true">
<EmptyState
v-if="filteredPlugins.length === 0 && !pluginStore.isLoading"
icon="puzzle"
title="No plugins installed"
:description="searchQuery ? `No plugins matching &quot;${searchQuery}&quot;.` : 'Install plugins from the Browse uMod tab or upload a custom .cs file.'"
/>
<div v-else-if="pluginStore.isLoading && filteredPlugins.length === 0" class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Loading plugins</span>
</div>
<table v-else class="plv__table">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
<tr>
<th>Plugin</th>
<th>Version</th>
<th>Source</th>
<th>Status</th>
<th>Wipe behavior</th>
<th class="plv__th-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="filteredPlugins.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="pluginStore.isLoading">Loading plugins...</template>
<template v-else>No plugins installed yet.</template>
</td>
</tr>
<tbody>
<tr
v-for="plugin in filteredPlugins"
:key="plugin.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ plugin.plugin_name }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)">
{{ sourceLabel(plugin.source) }}
</span>
<td class="plv__plugin-name">{{ plugin.plugin_name }}</td>
<td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
<td>
<Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 text-xs font-medium"
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'"
>
<span class="h-1.5 w-1.5 rounded-full" :class="plugin.is_loaded ? 'bg-green-500' : 'bg-neutral-600'" />
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
</span>
<td>
<Badge
:tone="plugin.is_loaded ? 'online' : 'offline'"
:dot="true"
:pulse="plugin.is_loaded"
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
</td>
<td class="px-4 py-3 text-xs text-neutral-500">
<td class="plv__secondary plv__wipe-cell">
<template v-if="plugin.never_wipe">Never wipe</template>
<template v-else>
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
</template>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
<td class="plv__td-right">
<div class="plv__row-actions">
<IconButton
:icon="plugin.is_loaded ? 'power' : 'play'"
variant="ghost"
size="sm"
:label="plugin.is_loaded ? 'Unload' : 'Load'"
@click="handleToggleLoad(plugin)"
class="p-1.5 rounded transition-colors"
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
:title="plugin.is_loaded ? 'Unload' : 'Load'"
>
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
</button>
<button
/>
<IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Uninstall"
@click="handleUninstall(plugin)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Uninstall"
>
<Trash2 class="w-4 h-4" />
</button>
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
<!-- Browse uMod -->
<!-- ===== BROWSE UMOD TAB ===== -->
<div v-if="tab === 'browse'">
<!-- Empty state: no search yet -->
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
</div>
<!-- No search yet -->
<Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="search"
title="Search uMod"
description="Type a plugin name above to search the uMod plugin directory."
/>
</Panel>
<!-- Loading -->
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
<p class="text-sm text-neutral-500">Searching uMod...</p>
</div>
<Panel v-else-if="pluginStore.isBrowseLoading">
<div class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Searching uMod</span>
</div>
</Panel>
<!-- No results -->
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
</div>
<Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="download"
title="No plugins found"
:description="`No uMod plugins matched &quot;${browseQuery}&quot;. Try a different search term.`"
/>
</Panel>
<!-- Results -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
<p class="text-xs text-neutral-500">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
&bull; Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
<div class="flex items-center gap-1">
<button
@click="browsePrev"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
>
&larr; Prev
</button>
<button
@click="browseNext"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
>
Next &rarr;
</button>
</div>
</div>
<table class="w-full">
<Panel v-else :flush-body="true">
<template #actions>
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
&middot; page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
</span>
<Button
variant="ghost"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
@click="browsePrev"
>Prev</Button>
<Button
variant="ghost"
size="sm"
icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
@click="browseNext"
>Next</Button>
</template>
<table class="plv__table">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
<tr>
<th>Plugin</th>
<th>Author</th>
<th>Version</th>
<th>Downloads</th>
<th class="plv__th-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<tr
v-for="result in browsePlugins"
:key="result.name"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
<td>
<div class="plv__browse-name">{{ result.title }}</div>
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
<td class="px-4 py-3 text-right">
<button
@click="installFromBrowse(result)"
<td class="plv__secondary">{{ result.author ?? '' }}</td>
<td class="plv__mono">{{ result.latest_release_version_formatted ?? '' }}</td>
<td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '' }}</td>
<td class="plv__td-right">
<Button
size="sm"
:variant="isAlreadyInstalled(result.name) ? 'secondary' : 'primary'"
:icon="installing === result.name ? 'loader' : 'download'"
:loading="installing === result.name"
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
:class="isAlreadyInstalled(result.name)
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
>
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
<Download v-else class="w-3.5 h-3.5" />
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
</button>
@click="installFromBrowse(result)"
>{{ isAlreadyInstalled(result.name) ? 'Installed' : 'Install' }}</Button>
</td>
</tr>
</tbody>
</table>
<!-- Bottom pagination -->
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
<p class="text-xs text-neutral-500">
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
<span class="plv__browse-meta">
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
<div class="flex items-center gap-1">
<button
@click="browsePrev"
</span>
<div class="plv__browse-pag">
<Button
variant="secondary"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
>
&larr; Previous
</button>
<button
@click="browseNext"
@click="browsePrev"
>Previous</Button>
<Button
variant="secondary"
size="sm"
icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
>
Next &rarr;
</button>
@click="browseNext"
>Next</Button>
</div>
</div>
</div>
</Panel>
</div>
<!-- Upload Custom Plugin -->
<div v-if="tab === 'upload'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
<p class="text-sm text-neutral-500 mb-6">
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
The file will be pushed to your server via the companion agent.
</p>
<!-- ===== UPLOAD CUSTOM TAB ===== -->
<div v-if="tab === 'upload'" class="plv__upload-wrap">
<Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
<!-- Drop zone -->
<div
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
:class="isDragOver
? 'border-oxide-500 bg-oxide-500/5'
: uploadFile
? 'border-green-600 bg-green-900/10'
: 'border-neutral-700 hover:border-neutral-600'"
class="plv__dropzone"
:class="{
'plv__dropzone--active': isDragOver,
'plv__dropzone--ready': !!uploadFile && !isDragOver,
}"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop"
@@ -445,65 +434,174 @@ onMounted(() => {
ref="uploadInput"
type="file"
accept=".cs"
class="hidden"
class="plv__file-hidden"
@change="handleFilePick"
/>
<!-- No file selected -->
<!-- No file -->
<template v-if="!uploadFile">
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
<div class="plv__drop-icon">
<Icon name="upload" :size="22" :stroke-width="1.75" />
</div>
<p class="plv__drop-label">Drop your .cs file here</p>
<p class="plv__drop-hint">or click to browse</p>
</template>
<!-- File selected -->
<template v-else>
<div class="flex items-center justify-center gap-3">
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
<div class="text-left">
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
<div class="plv__file-row">
<div class="plv__file-icon">
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
</div>
<button
<div class="plv__file-info">
<div class="plv__file-name">{{ uploadFile.name }}</div>
<div class="plv__file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</div>
</div>
<IconButton
icon="x"
variant="ghost"
size="sm"
label="Remove"
@click.stop="clearUpload"
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Remove"
>
<X class="w-4 h-4" />
</button>
/>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 mt-4">
<button
@click="handleUpload"
<div class="plv__upload-actions">
<Button
icon="upload"
:loading="isUploading"
:disabled="!uploadFile || isUploading"
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
<Upload v-else class="w-4 h-4" />
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
</button>
<button
@click="handleUpload"
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
<Button
v-if="uploadFile"
variant="ghost"
@click="clearUpload"
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
>Cancel</Button>
</div>
</div>
</Panel>
<!-- Info card -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<p class="text-xs text-neutral-500 leading-relaxed">
<span class="font-medium text-neutral-400">Note:</span>
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</p>
</div>
<Alert tone="info">
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</Alert>
</div>
</div>
</template>
<style scoped>
.plv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
/* Page head */
.plv__head {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.plv__head-id { display: flex; align-items: center; gap: 12px; }
.plv__head-chip {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
color: var(--accent); background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.plv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
.plv__head-actions { display: flex; align-items: center; gap: 12px; }
/* Stat pill */
.plv__stat-pill {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
}
.plv__stat-num { font-weight: 700; color: var(--accent-text); }
.plv__stat-sep { color: var(--text-muted); }
.plv__stat-total { color: var(--text-tertiary); }
.plv__stat-label { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-tertiary); }
/* Toolbar */
.plv__toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Loading */
.plv__loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
}
@keyframes plv-spin { to { transform: rotate(360deg); } }
.plv__spin { animation: plv-spin 0.7s linear infinite; }
/* Table */
.plv__table { width: 100%; border-collapse: collapse; }
.plv__table th {
padding: 10px 14px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.plv__table td {
padding: 10px 14px; font-size: var(--text-sm);
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
}
.plv__table tbody tr:last-child td { border-bottom: 0; }
.plv__table tbody tr:hover td { background: var(--surface-hover); }
.plv__th-right { text-align: right; }
.plv__td-right { text-align: right; }
.plv__plugin-name { font-weight: 500; }
.plv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
.plv__secondary { color: var(--text-secondary) !important; }
.plv__wipe-cell { font-size: var(--text-xs) !important; }
.plv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
/* Browse */
.plv__browse-meta { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__browse-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__browse-desc {
font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px;
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.plv__browse-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-top: 1px solid var(--border-subtle);
}
.plv__browse-pag { display: flex; gap: 8px; }
/* Upload */
.plv__upload-wrap { display: flex; flex-direction: column; gap: 12px; }
.plv__dropzone {
border: 2px dashed var(--border-default); border-radius: var(--radius-md);
padding: 40px 24px; text-align: center;
cursor: pointer; transition: var(--transition-colors);
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.plv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
.plv__dropzone--active { border-color: var(--accent); background: var(--accent-soft); }
.plv__dropzone--ready { border-color: var(--status-online-border); background: var(--status-online-soft); }
.plv__file-hidden { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.plv__drop-icon {
width: 46px; height: 46px; border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
background: var(--surface-raised-2); color: var(--text-tertiary);
box-shadow: var(--ring-default); margin-bottom: 4px;
}
.plv__drop-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.plv__drop-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
.plv__file-row { display: flex; align-items: center; gap: 12px; }
.plv__file-icon {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
background: var(--status-online-soft); color: var(--status-online);
box-shadow: inset 0 0 0 1px var(--status-online-border);
}
.plv__file-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__file-size { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__upload-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
</style>

View File

@@ -1,8 +1,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { Clock, Plus, Edit, Trash2, Power, Loader2 } 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 IconButton from '@/components/ds/core/IconButton.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'
interface ScheduledTask {
id: string
@@ -12,7 +18,7 @@ interface ScheduledTask {
timezone: string
is_active: boolean
next_run: string | null
task_config: Record<string, any>
task_config: Record<string, unknown>
}
const api = useApi()
@@ -31,6 +37,13 @@ const formData = ref({
const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
const TASK_TYPE_OPTIONS = [
{ value: 'restart', label: 'Restart' },
{ value: 'announcement', label: 'Announcement' },
{ value: 'command', label: 'Command' },
{ value: 'plugin_reload', label: 'Plugin reload' },
]
async function fetchTasks() {
isLoading.value = true
try {
@@ -95,167 +108,295 @@ async function toggleActive(task: ScheduledTask) {
await fetchTasks()
}
function taskTypeLabel(type: string): string {
return type.replace('_', ' ')
}
onMounted(() => {
fetchTasks()
})
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Clock class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Scheduled Tasks</h1>
<div class="schedules">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Operations</div>
<h1 class="page__title">Scheduled tasks</h1>
</div>
<button
@click="openCreateModal"
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"
>
<Plus class="w-4 h-4" />
New Task
</button>
<Button icon="plus" @click="openCreateModal">New task</Button>
</div>
<!-- Tasks Table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div v-if="isLoading" class="p-8 flex justify-center">
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
<!-- Tasks table -->
<Panel :flush-body="true" title="Tasks">
<div v-if="isLoading" class="loading-row">
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
</div>
<div v-else-if="tasks.length === 0" class="p-8 text-center text-neutral-500">
No scheduled tasks configured.
</div>
<table v-else class="w-full">
<thead class="bg-neutral-800/50 border-b border-neutral-800">
<EmptyState
v-else-if="tasks.length === 0"
icon="calendar-clock"
title="No scheduled tasks"
description="Create tasks to automate restarts, announcements, and commands."
>
<template #action>
<Button icon="plus" size="sm" @click="openCreateModal">New task</Button>
</template>
</EmptyState>
<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">Task Name</th>
<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">Schedule</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Timezone</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Next Run</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
<th>Task name</th>
<th>Type</th>
<th>Schedule</th>
<th>Timezone</th>
<th>Next run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-for="task in tasks" :key="task.id" class="hover:bg-neutral-800/30">
<td class="px-4 py-3 text-sm text-neutral-200">{{ task.task_name }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ task.task_type.replace('_', ' ') }}</td>
<td class="px-4 py-3 text-sm font-mono text-neutral-400">{{ task.cron_expression }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.timezone }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(task.next_run, '—') }}</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="task.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<tbody>
<tr v-for="task in tasks" :key="task.id">
<td class="td-primary">{{ task.task_name }}</td>
<td class="td-cap">{{ taskTypeLabel(task.task_type) }}</td>
<td class="td-mono">{{ task.cron_expression }}</td>
<td>{{ task.timezone }}</td>
<td class="td-mono">{{ safeDate(task.next_run, '—') }}</td>
<td>
<Badge :tone="task.is_active ? 'online' : 'neutral'">
{{ task.is_active ? 'Active' : 'Paused' }}
</span>
</Badge>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
<td>
<div class="row-actions">
<IconButton
icon="power"
size="sm"
:label="task.is_active ? 'Pause' : 'Activate'"
@click="toggleActive(task)"
class="text-neutral-400 hover:text-oxide-400 transition-colors"
:title="task.is_active ? 'Pause' : 'Activate'"
>
<Power class="w-4 h-4" />
</button>
<button
/>
<IconButton
icon="pencil"
size="sm"
label="Edit"
@click="openEditModal(task)"
class="text-neutral-400 hover:text-oxide-400 transition-colors"
>
<Edit class="w-4 h-4" />
</button>
<button
/>
<IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Delete"
@click="deleteTask(task.id)"
class="text-neutral-400 hover:text-red-400 transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
</div>
<!-- Create/Edit Modal -->
<!-- Create / Edit Modal -->
<Teleport to="body">
<div
v-if="showModal"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
class="modal-backdrop"
@click.self="showModal = false"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg w-full max-w-lg">
<div class="p-5 border-b border-neutral-800">
<h2 class="text-lg font-bold text-neutral-100">{{ editingTask ? 'Edit Task' : 'New Task' }}</h2>
<div class="modal">
<div class="modal__head">
<h2 class="modal__title">{{ editingTask ? 'Edit task' : 'New task' }}</h2>
<IconButton icon="x" label="Close" @click="showModal = false" />
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-sm text-neutral-400 mb-2">Task Name</label>
<input
v-model="formData.task_name"
type="text"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500"
placeholder="Daily restart"
/>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-2">Task Type</label>
<select
v-model="formData.task_type"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
>
<option value="restart">Restart</option>
<option value="announcement">Announcement</option>
<option value="command">Command</option>
<option value="plugin_reload">Plugin Reload</option>
</select>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-2">Cron Expression</label>
<input
v-model="formData.cron_expression"
type="text"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
placeholder="0 0 * * *"
/>
<p class="text-xs text-neutral-500 mt-1">Example: "0 0 * * *" = daily at midnight</p>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-2">Timezone</label>
<select
v-model="formData.timezone"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
>
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<div>
<label class="block text-sm text-neutral-400 mb-2">Task Config (JSON)</label>
<div class="modal__body">
<Input
v-model="formData.task_name"
label="Task name"
placeholder="Daily restart"
/>
<Select
v-model="formData.task_type"
label="Task type"
:options="TASK_TYPE_OPTIONS"
/>
<Input
v-model="formData.cron_expression"
label="Cron expression"
placeholder="0 0 * * *"
:mono="true"
hint="Example: 0 0 * * * = daily at midnight"
/>
<Select
v-model="formData.timezone"
label="Timezone"
:options="timezones"
/>
<div class="cc-field">
<span class="cc-field__label">Task config (JSON)</span>
<textarea
v-model="formData.task_config"
rows="4"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
placeholder='{"message": "Server restarting..."}'
class="cc-textarea cc-textarea--mono"
/>
</div>
</div>
<div class="p-5 border-t border-neutral-800 flex justify-end gap-3">
<button
@click="showModal = false"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveTask"
class="px-4 py-2 text-sm font-medium bg-oxide-500 hover:bg-oxide-600 text-white rounded-lg transition-colors"
>
{{ editingTask ? 'Update' : 'Create' }}
</button>
<div class="modal__foot">
<Button variant="secondary" @click="showModal = false">Cancel</Button>
<Button @click="saveTask">{{ editingTask ? 'Update' : 'Create' }}</Button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.schedules {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Page head */
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page__title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
margin-top: 4px;
}
/* Loading row */
.loading-row {
display: flex;
justify-content: center;
padding: 40px;
}
/* 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; }
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.td-cap { text-transform: capitalize; }
.row-actions {
display: flex;
align-items: center;
gap: 4px;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
padding: 16px;
}
.modal {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.modal__title {
font-size: var(--text-base);
font-weight: 700;
color: var(--text-primary);
}
.modal__body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
.modal__foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-subtle);
}
/* Textarea */
.cc-textarea {
width: 100%;
padding: 9px 11px;
background: var(--surface-inset);
border: 0;
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.5;
resize: vertical;
outline: 0;
transition: var(--transition-colors);
box-sizing: border-box;
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
.cc-textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,13 @@ import { ref, onMounted, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { StoreConfig } from '@/types'
import { Store, Save, Loader2, AlertTriangle, DollarSign } from 'lucide-vue-next'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.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 Switch from '@/components/ds/forms/Switch.vue'
const api = useApi()
const toast = useToastStore()
@@ -22,10 +28,18 @@ const isLoading = ref(false)
const saving = ref(false)
const isConfigured = ref(false)
// DS Input uses defineModel<string>() which emits string | undefined, but
// paypal_client_id is string | null in the DB type. Bridge null ↔ undefined
// so we never change what gets saved to the API.
const paypalClientId = computed({
get: () => config.value.paypal_client_id ?? undefined,
set: (v: string | undefined) => { config.value.paypal_client_id = v ?? null },
})
const currencyOptions = [
{ value: 'USD', label: 'USD - US Dollar' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'GBP', label: 'GBP - British Pound' },
{ value: 'USD', label: 'USD US Dollar' },
{ value: 'EUR', label: 'EUR Euro' },
{ value: 'GBP', label: 'GBP British Pound' },
]
const showPayPalWarning = computed(() => {
@@ -40,7 +54,6 @@ async function fetchConfig() {
isConfigured.value = !!data.config.store_name
} catch (err: any) {
if (err.response?.status === 404) {
// No config yet, use defaults
isConfigured.value = false
} else {
toast.error('Failed to load store configuration')
@@ -51,7 +64,6 @@ async function fetchConfig() {
}
async function saveConfig() {
// Validation
if (!config.value.store_name.trim()) {
toast.error('Store name is required')
return
@@ -73,7 +85,6 @@ async function saveConfig() {
enabled: config.value.enabled,
}
// Only include secret if it was entered
if (paypalSecret.value.trim()) {
payload.paypal_client_secret = paypalSecret.value
}
@@ -81,7 +92,7 @@ async function saveConfig() {
await api.put('/webstore/config', payload)
toast.success('Store configuration saved successfully')
isConfigured.value = true
paypalSecret.value = '' // Clear after save
paypalSecret.value = ''
} catch (err: any) {
const message = err.response?.data?.error || 'Failed to save configuration'
toast.error(message)
@@ -96,202 +107,181 @@ 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">
<Store class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Store Configuration</h1>
<p class="text-sm text-neutral-500 mt-0.5">
Configure your integrated webstore and PayPal settings
</p>
</div>
<div class="sc-page">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Management</div>
<h1 class="page__title">Store configuration</h1>
<p class="page__sub">Configure your integrated webstore and PayPal settings.</p>
</div>
<button
@click="saveConfig"
:disabled="saving || isLoading"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
<Button :loading="saving" :disabled="isLoading" icon="save" @click="saveConfig">
Save configuration
</Button>
</div>
<!-- Loading skeleton -->
<div v-if="isLoading" class="sc-loading">
<EmptyState icon="loader" title="Loading configuration" description="Fetching your store settings…" />
</div>
<!-- Empty state no config yet -->
<Panel v-else-if="!isConfigured && !config.store_name">
<EmptyState
icon="shopping-cart"
title="No store configured"
description="Set up your integrated webstore to start selling in-game items, ranks, and currency to your players. Fill out the form below to get started."
>
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" />
{{ saving ? 'Saving...' : 'Save Configuration' }}
</button>
</div>
<!-- Loading state -->
<div v-if="isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
<p class="text-sm text-neutral-500">Loading configuration...</p>
</div>
<!-- Empty state -->
<div
v-else-if="!isConfigured && !config.store_name"
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
>
<Store class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
<h3 class="text-lg font-medium text-neutral-200 mb-2">No Store Configured</h3>
<p class="text-sm text-neutral-500 mb-6 max-w-md mx-auto">
Set up your integrated webstore to start selling in-game items, ranks, and currency to your players.
</p>
<p class="text-xs text-neutral-600">
Fill out the form below to get started.
</p>
</div>
<template #action>
<Button size="sm" variant="outline" icon="arrow-down">Complete the form below</Button>
</template>
</EmptyState>
</Panel>
<!-- Configuration form -->
<div v-else class="space-y-6">
<!-- Store enable toggle with warning -->
<div
class="bg-neutral-900 border rounded-lg p-5"
:class="config.enabled ? 'border-oxide-500/50' : 'border-neutral-800'"
>
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-medium text-neutral-200">Enable Webstore</h2>
<p class="text-xs text-neutral-500 mt-1">
Allow players to purchase items from your store
</p>
<div v-else class="sc-form">
<!-- Enable toggle -->
<Panel title="Webstore status" subtitle="Allow players to purchase items from your store">
<div class="sc-toggle-row">
<div class="sc-toggle-row__info">
<span class="sc-toggle-row__label">Enable webstore</span>
<span class="sc-toggle-row__hint">Players will be able to browse and purchase items when enabled.</span>
</div>
<button
@click="config.enabled = !config.enabled"
:class="config.enabled ? 'bg-oxide-600' : 'bg-neutral-700'"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
>
<span
:class="config.enabled ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
></span>
</button>
<Switch v-model="config.enabled" />
</div>
<!-- Warning when enabled without PayPal -->
<div
<Alert
v-if="showPayPalWarning"
class="mt-4 flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
tone="warn"
title="PayPal configuration required"
class="sc-alert"
>
<AlertTriangle class="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div class="text-xs text-yellow-300">
<strong>PayPal configuration required.</strong> You must configure PayPal credentials below before the store can process transactions.
</div>
</div>
</div>
Configure PayPal credentials in the section below before the store can process transactions.
</Alert>
</Panel>
<!-- Basic store info -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Store Information</h2>
<div>
<label class="block text-xs text-neutral-500 mb-1">Store Name *</label>
<input
<!-- Store information -->
<Panel title="Store information" eyebrow="General">
<div class="sc-fields">
<Input
v-model="config.store_name"
type="text"
label="Store name"
placeholder="My Rust Server Store"
maxlength="200"
class="w-full 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"
:required="true"
hint="Displayed to players on the store page"
/>
<p class="text-xs text-neutral-600 mt-1">Displayed to players on the store page</p>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label>
<textarea
v-model="config.description"
placeholder="Welcome to our server store! Support us and get awesome in-game items..."
rows="3"
class="w-full 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 resize-none"
></textarea>
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the store page</p>
</div>
<label class="cc-field">
<span class="cc-field__label">Description <span class="sc-opt">(optional)</span></span>
<textarea
v-model="config.description"
class="cc-textarea"
rows="3"
placeholder="Welcome to our server store! Support us and get awesome in-game items…"
/>
<span class="cc-field__hint">Brief description shown on the store page.</span>
</label>
<div>
<label class="block text-xs text-neutral-500 mb-1">Currency</label>
<div class="relative">
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500 pointer-events-none" />
<select
v-model="config.currency"
class="w-full pl-9 pr-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 appearance-none cursor-pointer"
>
<option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<p class="text-xs text-neutral-600 mt-1">Currency used for all transactions</p>
<Select
v-model="config.currency"
label="Currency"
:options="currencyOptions"
hint="Currency used for all transactions"
/>
</div>
</div>
</Panel>
<!-- PayPal configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
<div>
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">PayPal Configuration</h2>
<p class="text-xs text-neutral-500 mt-1">
Get your API credentials from the
<a
href="https://developer.paypal.com/dashboard/"
target="_blank"
class="text-oxide-400 hover:text-oxide-300 underline"
>
PayPal Developer Dashboard
</a>
</p>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">PayPal Client ID *</label>
<input
v-model="config.paypal_client_id"
type="text"
<Panel title="PayPal configuration" eyebrow="Payments" subtitle="Get your API credentials from the PayPal Developer Dashboard.">
<div class="sc-fields">
<Input
v-model="paypalClientId"
label="PayPal Client ID"
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
:required="true"
:mono="true"
/>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-1">PayPal Client Secret *</label>
<input
<Input
v-model="paypalSecret"
label="PayPal Client Secret"
type="password"
placeholder="Enter to update (stored encrypted)"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
:mono="true"
hint="Stored encrypted. Leave blank to keep existing secret."
:required="true"
/>
<p class="text-xs text-neutral-600 mt-1">
Stored encrypted. Leave blank to keep existing secret.
</p>
</div>
<!-- Sandbox mode toggle -->
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700">
<div>
<p class="text-sm font-medium text-neutral-200">Sandbox Mode</p>
<p class="text-xs text-neutral-500 mt-1">
Use PayPal sandbox for testing (no real transactions)
</p>
<!-- Sandbox mode toggle -->
<div class="sc-sandbox">
<div class="sc-toggle-row">
<div class="sc-toggle-row__info">
<span class="sc-toggle-row__label">Sandbox mode</span>
<span class="sc-toggle-row__hint">Use PayPal sandbox for testing no real transactions.</span>
</div>
<Switch v-model="config.sandbox_mode" />
</div>
</div>
<button
@click="config.sandbox_mode = !config.sandbox_mode"
:class="config.sandbox_mode ? 'bg-yellow-600' : 'bg-green-600'"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
<Alert
v-if="!config.sandbox_mode && config.enabled"
tone="danger"
title="Production mode active"
>
<span
:class="config.sandbox_mode ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
></span>
</button>
Real transactions will be processed. Ensure your PayPal credentials are correct.
</Alert>
</div>
<!-- Production warning -->
<div
v-if="!config.sandbox_mode && config.enabled"
class="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"
>
<AlertTriangle class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<div class="text-xs text-red-300">
<strong>Production mode enabled.</strong> Real transactions will be processed. Ensure your PayPal credentials are correct.
</div>
</div>
</div>
</Panel>
</div>
</div>
</template>
<style scoped>
.sc-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.page__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; row-gap: 12px;
}
.page__title {
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 5px;
}
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
.sc-loading { padding: 20px 0; }
.sc-form { display: flex; flex-direction: column; gap: 16px; }
.sc-fields { display: flex; flex-direction: column; gap: 16px; }
/* Shared toggle row */
.sc-toggle-row {
display: flex; align-items: center; justify-content: space-between;
gap: 16px;
}
.sc-toggle-row__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sc-toggle-row__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.sc-toggle-row__hint { font-size: var(--text-xs); color: var(--text-tertiary); }
.sc-sandbox {
background: var(--surface-raised); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 14px 16px;
}
.sc-alert { margin-top: 14px; }
.sc-opt { font-weight: 400; color: var(--text-muted); }
/* Textarea token style */
.cc-textarea {
width: 100%; min-height: 80px; padding: 9px 11px;
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
box-shadow: var(--ring-default); resize: vertical;
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
line-height: var(--leading-normal); outline: none;
transition: var(--transition-colors);
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
</style>

View File

@@ -2,8 +2,16 @@
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import type { StoreCategory, StoreItem } from '@/types'
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
import { safeFixed } 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 IconButton from '@/components/ds/core/IconButton.vue'
import Tabs from '@/components/ds/navigation/Tabs.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 Checkbox from '@/components/ds/forms/Checkbox.vue'
const api = useApi()
@@ -42,17 +50,23 @@ const itemTypes = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
{ value: 'command', label: 'Custom Command', example: 'yourplugin.givereward {steam_id}' }
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
]
function typeBadgeClass(type: string): string {
switch (type) {
case 'kit': return 'bg-blue-500/15 text-blue-400'
case 'rank': return 'bg-purple-500/15 text-purple-400'
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
case 'command': return 'bg-oxide-500/15 text-oxide-400'
default: return 'bg-neutral-700/50 text-neutral-400'
const tabItems = computed(() => [
{ value: 'categories', label: 'Categories', count: categories.value.length },
{ value: 'items', label: 'Items', count: items.value.length },
])
type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
function typeTone(type: string): ItemTypeTone {
const map: Record<string, ItemTypeTone> = {
kit: 'info',
rank: 'accent',
currency: 'warn',
command: 'accent',
}
return map[type] ?? 'neutral'
}
function autoGenerateSlug(name: string): string {
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
}
async function saveItem() {
// Validate
if (!itemForm.value.name.trim()) {
alert('Item name is required')
return
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
function getCategoryName(categoryId: string | null): string {
if (!categoryId) return 'Uncategorized'
const cat = categories.value.find(c => c.id === categoryId)
return cat?.name || 'Unknown'
return cat?.name ?? 'Unknown'
}
const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
return type?.example || ''
return type?.example ?? ''
})
const categorySelectOptions = computed(() => [
{ value: '', label: 'Uncategorized' },
...categories.value.map(c => ({ value: c.id, label: c.name }))
])
const categorySelectValue = computed({
get: () => itemForm.value.category_id ?? '',
set: (v: string) => { itemForm.value.category_id = v || null }
})
onMounted(() => {
@@ -249,447 +272,408 @@ 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">
<ShoppingBag class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1>
<p class="text-sm text-neutral-500 mt-0.5">
{{ categories.length }} categories, {{ items.length }} items
</p>
</div>
<div class="si-page">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Management · Store</div>
<h1 class="page__title">Store items</h1>
<p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
</div>
<div class="flex items-center gap-3">
<button
@click="tab === 'categories' ? fetchCategories() : fetchItems()"
:disabled="isLoading"
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</button>
<button
v-if="tab === 'categories'"
@click="openCategoryModal()"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Category
</button>
<button
v-else
@click="openItemModal()"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Item
</button>
<div class="page__actions">
<IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
<Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
<Button v-else icon="plus" @click="openItemModal()">Add item</Button>
</div>
</div>
<!-- Tabs -->
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
<button
@click="tab = 'categories'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'categories' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Categories
</button>
<button
@click="tab = 'items'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'items' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Items
</button>
</div>
<!-- Tab bar + content panel -->
<Panel :flush-body="true">
<template #actions>
<Tabs v-model="tab" :items="tabItems" />
</template>
<!-- Categories Tab -->
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<!-- Categories table -->
<table v-if="tab === 'categories'" class="si-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">Name</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
<tr>
<th>Name</th>
<th>Slug</th>
<th class="si-col-num">Order</th>
<th>Visibility</th>
<th class="si-col-actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<tr v-if="categories.length === 0">
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading categories...</template>
<template v-else>No categories yet. Add one to organize your store items.</template>
<td colspan="5" class="si-empty-cell">
<EmptyState
icon="folder-open"
:title="isLoading ? 'Loading…' : 'No categories'"
:description="isLoading ? '' : 'Add a category to organize your store items.'"
/>
</td>
</tr>
<tr
v-for="category in categories"
:key="category.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ category.name }}</p>
<p v-if="category.description" class="text-xs text-neutral-500 truncate max-w-md">{{ category.description }}</p>
<tr v-for="category in categories" :key="category.id">
<td class="si-cell-primary">
<span class="si-name">{{ category.name }}</span>
<span v-if="category.description" class="si-sub">{{ category.description }}</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="category.visible ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<td><span class="si-mono">{{ category.slug }}</span></td>
<td class="si-col-num si-mono">{{ category.display_order }}</td>
<td>
<Badge :tone="category.visible ? 'online' : 'neutral'">
{{ category.visible ? 'Visible' : 'Hidden' }}
</span>
</Badge>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
@click="openCategoryModal(category)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteCategory(category)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
<td class="si-col-actions">
<div class="si-row-actions">
<IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(category)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Items Tab -->
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<!-- Items table -->
<table v-if="tab === 'items'" class="si-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">Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th>
<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">Price</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</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 text-right">Actions</th>
<tr>
<th>Item</th>
<th>Category</th>
<th>Type</th>
<th class="si-col-price">Price</th>
<th>Commands</th>
<th>Status</th>
<th class="si-col-actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<tr v-if="items.length === 0">
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading items...</template>
<template v-else>No items yet. Add items to start selling.</template>
<td colspan="7" class="si-empty-cell">
<EmptyState
icon="shopping-bag"
:title="isLoading ? 'Loading…' : 'No items'"
:description="isLoading ? '' : 'Add items to start selling.'"
/>
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ item.name }}</p>
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
<tr v-for="item in items" :key="item.id">
<td class="si-cell-primary">
<span class="si-name">{{ item.name }}</span>
<span v-if="item.description" class="si-sub">{{ item.description }}</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
{{ item.item_type }}
</span>
<td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
<td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
<td class="si-col-price">
<span class="si-price">${{ safeFixed(item.price, 2) }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-1 text-sm text-neutral-200">
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
{{ safeFixed(item.price, 2) }}
</div>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="item.enabled ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
<td>
<Badge :tone="item.enabled ? 'online' : 'neutral'">
{{ item.enabled ? 'Enabled' : 'Disabled' }}
</span>
</Badge>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
@click="openItemModal(item)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteItem(item)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
<td class="si-col-actions">
<div class="si-row-actions">
<IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(item)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
<!-- Category Modal -->
<!-- ===== Category modal ===== -->
<div
v-if="showCategoryModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="si-modal-backdrop"
@click.self="closeCategoryModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full">
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2>
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
<div class="si-modal">
<div class="si-modal__head">
<h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
<IconButton icon="x" label="Close" @click="closeCategoryModal" />
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
<input
v-model="categoryForm.name"
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
type="text"
placeholder="VIP Kits"
class="w-full 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"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
<input
v-model="categoryForm.slug"
type="text"
placeholder="vip-kits"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<div class="si-modal__body">
<Input
v-model="categoryForm.name"
label="Name"
placeholder="VIP Kits"
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
/>
<Input
v-model="categoryForm.slug"
label="Slug (URL-safe)"
placeholder="vip-kits"
:mono="true"
/>
<label class="cc-field">
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
<textarea
v-model="categoryForm.description"
class="cc-textarea"
rows="2"
placeholder="Premium kits for VIP players"
class="w-full 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 resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label>
<input
v-model.number="categoryForm.display_order"
type="number"
min="0"
class="w-full 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"
/>
</div>
<div class="flex items-center gap-3">
<input
v-model="categoryForm.visible"
type="checkbox"
id="category-visible"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
/>
<label for="category-visible" class="text-sm text-neutral-300">Visible to customers</label>
</div>
</label>
<Input
v-model="categoryForm.display_order as any"
label="Display order"
type="number"
/>
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
</div>
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeCategoryModal"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveCategory"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
</button>
<div class="si-modal__foot">
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
</div>
</div>
</div>
<!-- Item Modal -->
<!-- ===== Item modal ===== -->
<div
v-if="showItemModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="si-modal-backdrop"
@click.self="closeItemModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2>
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
<div class="si-modal si-modal--wide">
<div class="si-modal__head si-modal__head--sticky">
<h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
<IconButton icon="x" label="Close" @click="closeItemModal" />
</div>
<div class="p-6 space-y-6">
<!-- Basic Info -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label>
<input
v-model="itemForm.name"
type="text"
placeholder="VIP Starter Kit"
class="w-full 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"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<div class="si-modal__body">
<!-- Basic info section -->
<div class="si-section">
<div class="si-section__label">Basic information</div>
<Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
<label class="cc-field">
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
<textarea
v-model="itemForm.description"
class="cc-textarea"
rows="2"
placeholder="Get started with essential gear and resources"
class="w-full 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 resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Category</label>
<select
v-model="itemForm.category_id"
class="w-full 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 :value="null">Uncategorized</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
</label>
<Select
v-model="categorySelectValue"
label="Category"
:options="categorySelectOptions"
/>
</div>
<!-- Pricing -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label>
<div class="relative">
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model.number="itemForm.price"
type="number"
step="0.01"
min="0.01"
placeholder="9.99"
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>
<!-- Pricing section -->
<div class="si-section">
<div class="si-section__label">Pricing</div>
<Input
v-model="itemForm.price as any"
label="Price (USD)"
type="number"
prefix="$"
placeholder="9.99"
/>
</div>
<!-- Item Type -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3>
<div class="grid grid-cols-2 gap-2">
<!-- Item type section -->
<div class="si-section">
<div class="si-section__label">Item type</div>
<div class="si-type-grid">
<button
v-for="type in itemTypes"
:key="type.value"
type="button"
class="si-type-btn"
:class="itemForm.item_type === type.value ? 'si-type-btn--active' : ''"
@click="itemForm.item_type = type.value as any"
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
:class="itemForm.item_type === type.value
? 'bg-oxide-500/15 border-oxide-500 text-oxide-400'
: 'bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200'"
>
{{ type.label }}
</button>
</div>
</div>
<!-- Delivery Commands -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3>
<button
@click="addCommand"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
>
<Plus class="w-3 h-3" />
Add Command
</button>
<!-- Delivery commands section -->
<div class="si-section">
<div class="si-section__label-row">
<div class="si-section__label">Delivery commands</div>
<Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</Button>
</div>
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs">
<p class="text-neutral-400">Use placeholders:</p>
<p class="text-neutral-300 font-mono">{'{steam_id}'} - Player's Steam ID</p>
<p class="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p>
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
<div class="si-cmd-hint">
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{player_name}'}</span> Player's name</p>
<p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
</div>
<div class="space-y-2">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2">
<input
<div class="si-commands">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
<Input
v-model="itemForm.delivery_commands[index]"
type="text"
:mono="true"
placeholder="inventory.giveto {steam_id} rifle.ak 1"
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
<button
<IconButton
v-if="itemForm.delivery_commands.length > 1"
icon="trash-2"
label="Remove"
variant="danger"
size="sm"
@click="removeCommand(index)"
class="p-2 text-neutral-500 hover:text-red-400 rounded-lg transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
/>
</div>
</div>
</div>
<!-- Additional Settings -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
<input
v-model="itemForm.image_url"
type="text"
placeholder="https://example.com/image.png"
class="w-full 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"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label>
<input
v-model.number="itemForm.limit_per_player"
type="number"
min="0"
placeholder="Leave empty for unlimited"
class="w-full 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"
/>
</div>
<div class="flex items-center gap-3">
<input
v-model="itemForm.enabled"
type="checkbox"
id="item-enabled"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
/>
<label for="item-enabled" class="text-sm text-neutral-300">Enabled and available for purchase</label>
</div>
<!-- Additional settings section -->
<div class="si-section">
<div class="si-section__label">Additional settings</div>
<Input
v-model="itemForm.image_url"
label="Image URL"
placeholder="https://example.com/image.png"
hint="Optional preview image for the store page."
/>
<Input
v-model="itemForm.limit_per_player as any"
label="Purchase limit per player"
type="number"
placeholder="Leave empty for unlimited"
hint="Optional. Restricts how many times a player can purchase this item."
/>
<Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
</div>
</div>
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeItemModal"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveItem"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
{{ editingItem ? 'Save Changes' : 'Create Item' }}
</button>
<div class="si-modal__foot">
<Button variant="secondary" @click="closeItemModal">Cancel</Button>
<Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</Button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.si-page { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.page__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; row-gap: 12px;
}
.page__title {
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 5px;
}
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
.page__actions { display: flex; align-items: center; gap: 8px; }
/* Table */
.si-table { width: 100%; border-collapse: collapse; }
.si-table thead th {
padding: 10px 16px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.si-table tbody tr { border-bottom: 1px solid var(--border-subtle); }
.si-table tbody tr:last-child { border-bottom: none; }
.si-table tbody tr:hover { background: var(--surface-hover); }
.si-table td { padding: 11px 16px; vertical-align: middle; }
.si-cell-primary { display: flex; flex-direction: column; gap: 2px; }
.si-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.si-sub { font-size: var(--text-xs); color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
.si-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.si-text-secondary { font-size: var(--text-sm); color: var(--text-secondary); }
.si-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.si-col-num { width: 80px; text-align: right; }
.si-col-num.si-mono { text-align: right; }
.si-col-price { width: 90px; }
.si-col-actions { width: 80px; }
.si-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.si-empty-cell { padding: 0 !important; }
.si-spin { animation: si-spin 0.8s linear infinite; }
@keyframes si-spin { to { transform: rotate(360deg); } }
/* Modal */
.si-modal-backdrop {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
}
.si-modal {
background: var(--surface-base); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 520px; width: 100%;
display: flex; flex-direction: column; max-height: 90vh;
}
.si-modal--wide { max-width: 680px; }
.si-modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
flex: none;
}
.si-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
.si-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
.si-modal__body {
padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
}
.si-modal__foot {
display: flex; align-items: center; justify-content: flex-end;
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle);
flex: none;
}
/* Item modal sections */
.si-section { display: flex; flex-direction: column; gap: 12px; }
.si-section__label {
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
}
.si-section__label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* Item type selector */
.si-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.si-type-btn {
padding: 9px 14px; font-size: var(--text-sm); font-weight: 500; font-family: var(--font-sans);
border-radius: var(--radius-md); border: none; cursor: pointer;
background: var(--surface-inset); color: var(--text-secondary);
box-shadow: var(--ring-default); transition: var(--transition-colors);
}
.si-type-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.si-type-btn--active {
background: var(--accent-soft); color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Command hint */
.si-cmd-hint {
background: var(--surface-inset); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 10px 13px;
display: flex; flex-direction: column; gap: 3px;
}
.si-cmd-hint__row { font-size: var(--text-xs); color: var(--text-secondary); }
.si-cmd-hint__mono { font-family: var(--font-mono); color: var(--text-primary); margin-right: 6px; }
.si-cmd-hint__example { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; }
.si-commands { display: flex; flex-direction: column; gap: 8px; }
.si-command-row { display: flex; align-items: center; gap: 8px; }
.si-opt { font-weight: 400; color: var(--text-muted); }
/* Textarea token style */
.cc-textarea {
width: 100%; min-height: 68px; padding: 9px 11px;
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
box-shadow: var(--ring-default); resize: vertical;
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
line-height: var(--leading-normal); outline: none;
transition: var(--transition-colors);
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
</style>

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { DollarSign, TrendingUp, Clock, AlertCircle, Download, RefreshCw } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import type { StoreTransaction } from '@/types'
import { safeCurrency, safeDate } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Select from '@/components/ds/forms/Select.vue'
const api = useApi()
@@ -45,15 +50,16 @@ const formatCurrency = (amount: number, currency: string = 'USD'): string => {
return safeCurrency(amount, symbol)
}
// Status badge color classes
const statusBadgeClass = (status: string): string => {
// Status badge tone map
type BadgeTone = 'online' | 'warn' | 'info' | 'offline' | 'neutral'
const statusTone = (status: string): BadgeTone => {
switch (status) {
case 'delivered': return 'bg-green-500/10 text-green-400'
case 'paid': return 'bg-yellow-500/10 text-yellow-400'
case 'pending': return 'bg-blue-500/10 text-blue-400'
case 'failed': return 'bg-red-500/10 text-red-400'
case 'refunded': return 'bg-neutral-500/10 text-neutral-400'
default: return 'bg-neutral-700/50 text-neutral-400'
case 'delivered': return 'online'
case 'paid': return 'warn'
case 'pending': return 'info'
case 'failed': return 'offline'
case 'refunded': return 'neutral'
default: return 'neutral'
}
}
@@ -77,6 +83,10 @@ const loadTransactions = async () => {
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
// Render revenue chart (last 30 days, grouped by day)
const renderRevenueChart = () => {
if (!revenueChart.value || transactions.value.length === 0) return
@@ -106,16 +116,24 @@ const renderRevenueChart = () => {
d.setDate(d.getDate() - i)
const dateKey = d.toLocaleDateString('en-US')
dates.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }))
revenueData.push(revenueByDate.get(dateKey) || 0)
revenueData.push(revenueByDate.get(dateKey) ?? 0)
}
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
revenueChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => {
const value = params[0]?.data
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
@@ -131,15 +149,15 @@ const renderRevenueChart = () => {
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Revenue ($)',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080', formatter: (value: number) => `$${value}` }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10, formatter: (value: number) => `$${value}` }
},
series: [
{
@@ -186,163 +204,277 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<div class="revenue-view">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<DollarSign class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Revenue Dashboard</h1>
</div>
<div class="flex items-center gap-3">
<button
<div class="revenue-view__header">
<h1 class="revenue-view__title">Revenue dashboard</h1>
<div class="revenue-view__controls">
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="loading"
@click="loadTransactions"
:disabled="loading"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
Refresh
</button>
<button
@click="exportCSV"
</Button>
<Button
variant="secondary"
size="sm"
icon="download"
:disabled="transactions.length === 0"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
@click="exportCSV"
>
<Download class="w-4 h-4" />
Export CSV
</button>
</Button>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading transaction data...</div>
<div v-if="loading" class="revenue-view__loading">
<span class="revenue-view__loading-text">Loading transaction data...</span>
</div>
<template v-else>
<!-- Summary Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<DollarSign class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Total Revenue</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ formatCurrency(totalRevenue) }}</p>
<p class="text-xs text-neutral-600 mt-1">Last 100 transactions</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Total Transactions</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ totalTransactions }}</p>
<p class="text-xs text-neutral-600 mt-1">All time</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Pending Deliveries</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ pendingDeliveries }}</p>
<p class="text-xs text-neutral-600 mt-1">Paid, not delivered</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Refunds</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ refunds }}</p>
<p class="text-xs text-neutral-600 mt-1">Total refunded</p>
</div>
<div class="revenue-view__stats">
<StatCard
label="Total revenue"
:value="formatCurrency(totalRevenue)"
icon="dollar-sign"
note="Last 100 transactions"
/>
<StatCard
label="Total transactions"
:value="totalTransactions"
icon="trending-up"
note="All time"
/>
<StatCard
label="Pending deliveries"
:value="pendingDeliveries"
icon="clock"
note="Paid, not delivered"
/>
<StatCard
label="Refunds"
:value="refunds"
icon="alert-circle"
note="Total refunded"
/>
</div>
<!-- Revenue Chart -->
<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">Revenue Over Time (Last 30 Days)</h2>
<div ref="revenueChart" class="h-64"></div>
</div>
<Panel title="Revenue over time (last 30 days)">
<div ref="revenueChart" class="revenue-view__chart-area"></div>
</Panel>
<!-- Transaction Table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Transaction History</h2>
<div class="flex items-center gap-2">
<label class="text-xs text-neutral-500">Filter:</label>
<select
v-model="statusFilter"
class="px-2 py-1 text-xs bg-neutral-800 border border-neutral-700 rounded text-neutral-300 focus:outline-none focus:border-oxide-500"
>
<option value="all">All</option>
<option value="delivered">Delivered</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
</div>
</div>
<Panel title="Transaction history" :flush-body="true">
<template #actions>
<Select
:options="[
{ value: 'all', label: 'All statuses' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'paid', label: 'Paid' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
{ value: 'refunded', label: 'Refunded' }
]"
v-model="statusFilter"
size="sm"
/>
</template>
<table class="w-full">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Date</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount</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">Delivered</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="filteredTransactions.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="statusFilter !== 'all'">No {{ statusFilter }} transactions found.</template>
<template v-else>No transactions yet. Sales will appear here.</template>
</td>
</tr>
<tr
v-for="txn in filteredTransactions"
:key="txn.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm text-neutral-300">{{ formatDate(txn.created_at) }}</p>
</td>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ txn.player_name || 'Unknown' }}</p>
<p class="text-xs text-neutral-500 font-mono">{{ txn.steam_id }}</p>
</td>
<td class="px-4 py-3">
<p class="text-sm text-neutral-300">{{ txn.item_id || '' }}</p>
</td>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-200">{{ formatCurrency(txn.amount, txn.currency) }}</p>
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full uppercase"
:class="statusBadgeClass(txn.status)"
>
{{ txn.status }}
</span>
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="txn.delivered ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
{{ txn.delivered ? 'Yes' : 'No' }}
</span>
<p v-if="txn.delivered_at" class="text-xs text-neutral-600 mt-1">
{{ formatDate(txn.delivered_at) }}
</p>
</td>
</tr>
</tbody>
</table>
</div>
<div class="revenue-view__table-wrap">
<table class="revenue-view__table">
<thead>
<tr class="revenue-view__thead-row">
<th class="revenue-view__th revenue-view__th--left">Date</th>
<th class="revenue-view__th revenue-view__th--left">Player</th>
<th class="revenue-view__th revenue-view__th--left">Item</th>
<th class="revenue-view__th revenue-view__th--right">Amount</th>
<th class="revenue-view__th revenue-view__th--left">Status</th>
<th class="revenue-view__th revenue-view__th--left">Delivered</th>
</tr>
</thead>
<tbody>
<tr v-if="filteredTransactions.length === 0">
<td colspan="6" class="revenue-view__td-empty">
<EmptyState
icon="dollar-sign"
:title="statusFilter !== 'all' ? `No ${statusFilter} transactions` : 'No transactions yet'"
:description="statusFilter !== 'all' ? '' : 'Sales will appear here once customers make purchases.'"
/>
</td>
</tr>
<tr
v-for="txn in filteredTransactions"
:key="txn.id"
class="revenue-view__row"
>
<td class="revenue-view__td">
<p class="revenue-view__cell-text">{{ formatDate(txn.created_at) }}</p>
</td>
<td class="revenue-view__td">
<p class="revenue-view__cell-primary">{{ txn.player_name || 'Unknown' }}</p>
<p class="revenue-view__cell-mono">{{ txn.steam_id }}</p>
</td>
<td class="revenue-view__td">
<p class="revenue-view__cell-text">{{ txn.item_id || '—' }}</p>
</td>
<td class="revenue-view__td revenue-view__td--right">
<p class="revenue-view__cell-primary revenue-view__cell-mono">{{ formatCurrency(txn.amount, txn.currency) }}</p>
</td>
<td class="revenue-view__td">
<Badge :tone="statusTone(txn.status)" uppercase>{{ txn.status }}</Badge>
</td>
<td class="revenue-view__td">
<Badge :tone="txn.delivered ? 'online' : 'neutral'">
{{ txn.delivered ? 'Yes' : 'No' }}
</Badge>
<p v-if="txn.delivered_at" class="revenue-view__cell-sub">
{{ formatDate(txn.delivered_at) }}
</p>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
</template>
</div>
</template>
<style scoped>
.revenue-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.revenue-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.revenue-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.revenue-view__controls {
display: flex;
align-items: center;
gap: 8px;
}
.revenue-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.revenue-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.revenue-view__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 1024px) {
.revenue-view__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.revenue-view__chart-area {
height: 256px;
}
.revenue-view__table-wrap {
overflow-x: auto;
}
.revenue-view__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.revenue-view__thead-row {
border-bottom: 1px solid var(--border-subtle);
}
.revenue-view__th {
padding: 12px 16px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.revenue-view__th--left {
text-align: left;
}
.revenue-view__th--right {
text-align: right;
}
.revenue-view__row {
border-bottom: 1px solid var(--border-subtle);
transition: background var(--dur-fast) var(--ease-standard);
}
.revenue-view__row:hover {
background: var(--surface-hover);
}
.revenue-view__td {
padding: 12px 16px;
vertical-align: top;
}
.revenue-view__td--right {
text-align: right;
}
.revenue-view__td-empty {
padding: 0;
}
.revenue-view__cell-text {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.revenue-view__cell-primary {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
}
.revenue-view__cell-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.revenue-view__cell-sub {
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: 2px;
}
</style>

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { BarChart3, TrendingUp, Clock, Target, Download, Zap } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import type { WipePerformanceMetrics } from '@/types'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const api = useApi()
@@ -42,9 +47,22 @@ const loadAnalytics = async () => {
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!metrics.value) return
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
// Success Rate Timeline
if (successRateChart.value) {
if (successRateChartInstance) {
@@ -62,9 +80,9 @@ const renderCharts = () => {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => {
const status = params[0].data === 1 ? 'Success' : 'Failed'
const color = params[0].data === 1 ? '#10b981' : '#ef4444'
@@ -81,17 +99,19 @@ const renderCharts = () => {
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
min: 0,
max: 1,
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: {
color: '#808080',
color: labelColor,
fontFamily: mono,
fontSize: 10,
formatter: (value: number) => value === 1 ? 'Success' : 'Failed'
}
},
@@ -122,9 +142,9 @@ const renderCharts = () => {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' }
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
},
grid: {
left: '3%',
@@ -136,15 +156,15 @@ const renderCharts = () => {
xAxis: {
type: 'category',
data: ['Day 1', 'Day 2', 'Day 3'],
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Avg Players',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
series: [
{
@@ -157,8 +177,8 @@ const renderCharts = () => {
],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#CE422B' },
{ offset: 1, color: '#8B2E1F' }
{ offset: 0, color: accent },
{ offset: 1, color: accent + '99' }
])
},
barWidth: '50%'
@@ -184,9 +204,9 @@ const renderCharts = () => {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => {
return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes`
}
@@ -201,15 +221,15 @@ const renderCharts = () => {
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Minutes',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
},
series: [
{
@@ -264,128 +284,190 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<div class="wipe-analytics-view">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Zap class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Wipe Analytics</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="downloadCSV"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<Download class="w-4 h-4" />
<div class="wipe-analytics-view__header">
<h1 class="wipe-analytics-view__title">Wipe analytics</h1>
<div class="wipe-analytics-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['6', '12', 'all'] as const)"
:key="opt"
@click="timeRange = opt"
class="px-3 py-2 text-sm font-medium transition-colors"
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt === 'all' ? 'All Time' : `Last ${opt} Wipes` }}
</button>
</div>
</Button>
<Tabs
:items="[
{ value: '6', label: 'Last 6 wipes' },
{ value: '12', label: 'Last 12 wipes' },
{ value: 'all', label: 'All time' }
]"
v-model="timeRange"
variant="pill"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading wipe analytics...</div>
<div v-if="loading" class="wipe-analytics-view__loading">
<span class="wipe-analytics-view__loading-text">Loading wipe analytics...</span>
</div>
<template v-else-if="metrics">
<!-- Insight Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Target class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Success Rate</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(metrics?.success_rate_percent, 1) }}%</p>
<p class="text-xs text-neutral-600 mt-1">{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Avg Duration</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ formatDuration(metrics.avg_duration_seconds) }}</p>
<p class="text-xs text-neutral-600 mt-1">Per wipe</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Peak Population</p>
</div>
<p class="text-2xl font-bold text-neutral-100">Day 1</p>
<p class="text-xs text-neutral-600 mt-1">{{ safeFixed(metrics?.population_curve?.day_1_avg, 1) }} avg players</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Optimal Timing</p>
</div>
<p class="text-2xl font-bold text-neutral-100">{{ metrics.optimal_wipe_day }}</p>
<p class="text-xs text-neutral-600 mt-1">{{ metrics.optimal_wipe_hour }}:00</p>
</div>
<div class="wipe-analytics-view__stats">
<StatCard
label="Success rate"
:value="safeFixed(metrics?.success_rate_percent, 1)"
unit="%"
icon="target"
:note="`${metrics.successful_wipes}/${metrics.total_wipes} wipes`"
/>
<StatCard
label="Avg duration"
:value="formatDuration(metrics.avg_duration_seconds)"
icon="clock"
note="Per wipe"
/>
<StatCard
label="Peak population"
value="Day 1"
icon="trending-up"
:note="`${safeFixed(metrics?.population_curve?.day_1_avg, 1)} avg players`"
/>
<StatCard
label="Optimal timing"
:value="metrics.optimal_wipe_day"
icon="bar-chart-3"
:note="`${metrics.optimal_wipe_hour}:00`"
/>
</div>
<!-- Actionable Insight Banner -->
<div v-if="metrics.total_wipes > 3" class="bg-oxide-500/10 border border-oxide-500/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<Target class="w-5 h-5 text-oxide-400 mt-0.5" />
<div>
<p class="text-sm font-medium text-neutral-100 mb-1">Recommendations</p>
<ul class="text-sm text-neutral-300 space-y-1">
<li> Your best wipe day is <span class="font-semibold text-oxide-400">{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</span> based on post-wipe population peaks.</li>
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
Players peak on Day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <span class="font-semibold text-oxide-400">weekly wipes</span> to maintain engagement.
</li>
<li v-if="metrics.avg_duration_seconds > 600">
Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
</li>
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
{{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
</li>
</ul>
</div>
</div>
</div>
<Alert
v-if="metrics.total_wipes > 3"
tone="accent"
title="Recommendations"
>
<ul class="wipe-analytics-view__recs">
<li>Your best wipe day is <strong>{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</strong> based on post-wipe population peaks.</li>
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
Players peak on day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <strong>weekly wipes</strong> to maintain engagement.
</li>
<li v-if="metrics.avg_duration_seconds > 600">
Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
</li>
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
{{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
</li>
</ul>
</Alert>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<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">Wipe Success Timeline</h2>
<div ref="successRateChart" class="h-64"></div>
</div>
<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">Population Curve Post-Wipe</h2>
<div ref="populationCurveChart" class="h-64"></div>
</div>
<div class="wipe-analytics-view__charts">
<Panel title="Wipe success timeline">
<div ref="successRateChart" class="wipe-analytics-view__chart-area"></div>
</Panel>
<Panel title="Population curve post-wipe">
<div ref="populationCurveChart" class="wipe-analytics-view__chart-area"></div>
</Panel>
</div>
<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">Wipe Duration Trend</h2>
<div ref="durationTrendChart" class="h-64"></div>
</div>
<Panel title="Wipe duration trend">
<div ref="durationTrendChart" class="wipe-analytics-view__chart-area"></div>
</Panel>
<!-- No Data State -->
<div v-if="metrics.total_wipes === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
<div class="text-center">
<Zap class="w-12 h-12 text-neutral-700 mx-auto mb-3" />
<p class="text-neutral-400 mb-1">No wipe data yet</p>
<p class="text-sm text-neutral-600">Wipe analytics will appear after your first scheduled or manual wipe.</p>
</div>
</div>
<Panel v-if="metrics.total_wipes === 0">
<EmptyState
icon="zap"
title="No wipe data yet"
description="Wipe analytics will appear after your first scheduled or manual wipe."
/>
</Panel>
</template>
</div>
</template>
<style scoped>
.wipe-analytics-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.wipe-analytics-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wipe-analytics-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.wipe-analytics-view__controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.wipe-analytics-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.wipe-analytics-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.wipe-analytics-view__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 1024px) {
.wipe-analytics-view__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.wipe-analytics-view__charts {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 1024px) {
.wipe-analytics-view__charts {
grid-template-columns: 1fr 1fr;
}
}
.wipe-analytics-view__chart-area {
height: 256px;
}
.wipe-analytics-view__recs {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
}
.wipe-analytics-view__recs li::before {
content: '• ';
}
</style>

View File

@@ -3,7 +3,13 @@ import { ref, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useToastStore } from '@/stores/toast'
import type { WipeProfile } from '@/types'
import { FileText, Plus, ChevronDown, ChevronRight, Edit2, Trash2, X } from 'lucide-vue-next'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const wipeStore = useWipeStore()
const toast = useToastStore()
@@ -135,288 +141,489 @@ 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">
<FileText class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
<div class="profiles">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Operations</div>
<h1 class="page__title">Wipe profiles</h1>
</div>
<button
@click="openCreateModal"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
<Button icon="plus" @click="openCreateModal">New profile</Button>
</div>
<!-- Empty state -->
<Panel v-if="wipeStore.profiles.length === 0">
<EmptyState
icon="file-text"
title="No wipe profiles"
description="Create a profile to define pre-wipe and post-wipe behavior."
>
<Plus class="w-4 h-4" />
New Profile
</button>
</div>
<template #action>
<Button icon="plus" size="sm" @click="openCreateModal">New profile</Button>
</template>
</EmptyState>
</Panel>
<!-- Profiles -->
<div v-if="wipeStore.profiles.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<FileText class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Wipe Profiles</h3>
<p class="text-sm text-neutral-500">Create a profile to define pre-wipe and post-wipe behavior.</p>
</div>
<div v-else class="space-y-3">
<div
<!-- Profile list -->
<div v-else class="profile-list">
<Panel
v-for="profile in wipeStore.profiles"
:key="profile.id"
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
:flush-body="true"
>
<div class="flex items-center">
<template #title-append>
<!-- Nothing title is injected via Panel slot at row level instead -->
</template>
<!-- Accordion row header -->
<div class="profile-row">
<button
type="button"
class="profile-toggle"
@click="toggle(profile.id)"
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
>
<div>
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p>
<div class="profile-meta">
<span class="profile-name">{{ profile.profile_name }}</span>
<span class="profile-desc">{{ profile.description || 'No description' }}</span>
</div>
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
<span class="profile-chev" :class="expandedId === profile.id && 'profile-chev--open'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
<div class="flex items-center gap-1 pr-4">
<button
@click="openEditModal(profile)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteProfile(profile)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
<div class="profile-actions">
<IconButton icon="pencil" size="sm" label="Edit" @click="openEditModal(profile)" />
<IconButton icon="trash-2" variant="danger" size="sm" label="Delete" @click="deleteProfile(profile)" />
</div>
</div>
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
<div class="grid grid-cols-2 gap-6">
<!-- Expanded detail -->
<div v-if="expandedId === profile.id" class="profile-detail">
<div class="detail-grid">
<!-- Pre-wipe -->
<div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Pre-Wipe</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">Backup before wipe</span>
<span :class="profile.pre_wipe_config.backup_before_wipe ? 'text-green-400' : 'text-neutral-600'">
<div class="t-eyebrow detail-eyebrow">Pre-wipe</div>
<div class="detail-rows">
<div class="detail-kv">
<span class="detail-k">Backup before wipe</span>
<Badge :tone="profile.pre_wipe_config.backup_before_wipe ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Kick players</span>
<span :class="profile.pre_wipe_config.kick_players_before_wipe ? 'text-green-400' : 'text-neutral-600'">
<div class="detail-kv">
<span class="detail-k">Kick players</span>
<Badge :tone="profile.pre_wipe_config.kick_players_before_wipe ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Final save</span>
<span :class="profile.pre_wipe_config.run_final_save ? 'text-green-400' : 'text-neutral-600'">
<div class="detail-kv">
<span class="detail-k">Final save</span>
<Badge :tone="profile.pre_wipe_config.run_final_save ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Discord announce</span>
<span :class="profile.pre_wipe_config.discord_pre_announce ? 'text-green-400' : 'text-neutral-600'">
<div class="detail-kv">
<span class="detail-k">Discord announce</span>
<Badge :tone="profile.pre_wipe_config.discord_pre_announce ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
</div>
</div>
<!-- Post-wipe -->
<div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Post-Wipe</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">Verify server started</span>
<span :class="profile.post_wipe_config.verify_server_started ? 'text-green-400' : 'text-neutral-600'">
<div class="t-eyebrow detail-eyebrow">Post-wipe</div>
<div class="detail-rows">
<div class="detail-kv">
<span class="detail-k">Verify server started</span>
<Badge :tone="profile.post_wipe_config.verify_server_started ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Verify plugins loaded</span>
<span :class="profile.post_wipe_config.verify_plugins_loaded ? 'text-green-400' : 'text-neutral-600'">
<div class="detail-kv">
<span class="detail-k">Verify plugins loaded</span>
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Rollback on failure</span>
<span :class="profile.post_wipe_config.rollback_on_failure ? 'text-oxide-400' : 'text-neutral-600'">
<div class="detail-kv">
<span class="detail-k">Rollback on failure</span>
<Badge :tone="profile.post_wipe_config.rollback_on_failure ? 'accent' : 'neutral'">
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }}
</span>
</Badge>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Max restart attempts</span>
<span class="text-neutral-300">{{ profile.post_wipe_config.max_restart_attempts }}</span>
<div class="detail-kv">
<span class="detail-k">Max restart attempts</span>
<span class="detail-v-mono">{{ profile.post_wipe_config.max_restart_attempts }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Panel>
</div>
</div>
<!-- Create / Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@click.self="closeModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Modal header -->
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2>
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
</div>
<Teleport to="body">
<div
v-if="showModal"
class="modal-backdrop"
@click.self="closeModal"
>
<div class="modal">
<!-- Modal header -->
<div class="modal__head">
<h2 class="modal__title">{{ editingProfile ? 'Edit profile' : 'New profile' }}</h2>
<IconButton icon="x" label="Close" @click="closeModal" />
</div>
<div class="p-6 space-y-6">
<!-- Basic Info -->
<div class="space-y-4">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
<input
<div class="modal__body">
<!-- Basic info -->
<div class="form-section">
<div class="t-eyebrow form-section__eyebrow">Basic information</div>
<Input
v-model="form.profile_name"
type="text"
placeholder="Default Wipe Profile"
class="w-full 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"
label="Profile name"
placeholder="Default wipe profile"
:required="true"
/>
<div class="cc-field">
<span class="cc-field__label">Description (optional)</span>
<textarea
v-model="form.description"
rows="2"
placeholder="Standard wipe configuration for monthly force wipes"
class="cc-textarea"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<textarea
v-model="form.description"
rows="2"
placeholder="Standard wipe configuration for monthly force wipes"
class="w-full 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 resize-none"
/>
</div>
</div>
<!-- Pre-Wipe Config -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Enabled
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.backup_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Backup before wipe
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.run_final_save" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Run final save
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.kick_players_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Kick players before wipe
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.discord_pre_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Discord pre-announce
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.pushbullet_notify" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Pushbullet notify
</label>
</div>
<div v-if="form.pre_wipe_config.kick_players_before_wipe">
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label>
<input
<!-- Pre-wipe config -->
<div class="form-section">
<div class="form-section__row">
<div class="t-eyebrow">Pre-wipe</div>
<Checkbox
v-model="form.pre_wipe_config.enabled"
label="Enabled"
/>
</div>
<div class="check-grid">
<Checkbox
v-model="form.pre_wipe_config.backup_before_wipe"
label="Backup before wipe"
/>
<Checkbox
v-model="form.pre_wipe_config.run_final_save"
label="Run final save"
/>
<Checkbox
v-model="form.pre_wipe_config.kick_players_before_wipe"
label="Kick players before wipe"
/>
<Checkbox
v-model="form.pre_wipe_config.discord_pre_announce"
label="Discord pre-announce"
/>
<Checkbox
v-model="form.pre_wipe_config.pushbullet_notify"
label="Pushbullet notify"
/>
</div>
<Input
v-if="form.pre_wipe_config.kick_players_before_wipe"
v-model="form.pre_wipe_config.kick_message"
type="text"
label="Kick message"
placeholder="Server is wiping, back soon!"
class="w-full 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"
/>
</div>
</div>
<!-- Post-Wipe Config -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Enabled
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_server_started" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify server started
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_correct_map" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify correct map
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_plugins_loaded" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify plugins loaded
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_player_slots_open" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify player slots open
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.rollback_on_failure" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Rollback on failure
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.discord_post_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Discord post-announce
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label>
<input
v-model.number="form.post_wipe_config.max_restart_attempts"
type="number"
min="1"
max="10"
class="w-full 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"
<!-- Post-wipe config -->
<div class="form-section">
<div class="form-section__row">
<div class="t-eyebrow">Post-wipe</div>
<Checkbox
v-model="form.post_wipe_config.enabled"
label="Enabled"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label>
<input
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
type="number"
min="30"
max="600"
class="w-full 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"
<div class="check-grid">
<Checkbox
v-model="form.post_wipe_config.verify_server_started"
label="Verify server started"
/>
<Checkbox
v-model="form.post_wipe_config.verify_correct_map"
label="Verify correct map"
/>
<Checkbox
v-model="form.post_wipe_config.verify_plugins_loaded"
label="Verify plugins loaded"
/>
<Checkbox
v-model="form.post_wipe_config.verify_player_slots_open"
label="Verify player slots open"
/>
<Checkbox
v-model="form.post_wipe_config.rollback_on_failure"
label="Rollback on failure"
/>
<Checkbox
v-model="form.post_wipe_config.discord_post_announce"
label="Discord post-announce"
/>
</div>
<div class="number-grid">
<div class="cc-field">
<span class="cc-field__label">Max restart attempts</span>
<span class="cc-input cc-input--mono">
<input
v-model.number="form.post_wipe_config.max_restart_attempts"
type="number"
min="1"
max="10"
/>
</span>
</div>
<div class="cc-field">
<span class="cc-field__label">Health check timeout (s)</span>
<span class="cc-input cc-input--mono">
<input
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
type="number"
min="30"
max="600"
/>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Modal footer -->
<div class="sticky bottom-0 bg-neutral-900 border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveProfile"
:disabled="isSaving"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 rounded-lg transition-colors"
>
{{ isSaving ? 'Saving...' : (editingProfile ? 'Save Changes' : 'Create Profile') }}
</button>
<!-- Modal footer -->
<div class="modal__foot">
<Button variant="secondary" @click="closeModal">Cancel</Button>
<Button
:loading="isSaving"
@click="saveProfile"
>
{{ editingProfile ? 'Save changes' : 'Create profile' }}
</Button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.profiles {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Page head */
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page__title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
margin-top: 4px;
}
/* Profile list */
.profile-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Profile row inside panel */
.profile-row {
display: flex;
align-items: center;
}
.profile-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
gap: 12px;
transition: var(--transition-colors);
}
.profile-toggle:hover { background: var(--surface-hover); }
.profile-meta { display: flex; flex-direction: column; gap: 2px; }
.profile-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.profile-desc {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.profile-chev {
color: var(--text-tertiary);
transition: transform var(--dur-base) var(--ease-standard);
display: flex;
}
.profile-chev--open { transform: rotate(180deg); }
.profile-actions {
display: flex;
align-items: center;
gap: 4px;
padding-right: 12px;
}
/* Expanded detail */
.profile-detail {
border-top: 1px solid var(--border-subtle);
padding: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.detail-eyebrow { margin-bottom: 10px; }
.detail-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-kv {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.detail-k {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.detail-v-mono {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
padding: 16px;
}
.modal {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
width: 100%;
max-width: 660px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
flex: none;
}
.modal__title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--text-primary);
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
}
.modal__foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-subtle);
flex: none;
}
/* Form sections */
.form-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-section__eyebrow { margin-bottom: 4px; }
.form-section__row {
display: flex;
align-items: center;
justify-content: space-between;
}
.check-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.number-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
/* Textarea token-based */
.cc-textarea {
width: 100%;
padding: 9px 11px;
background: var(--surface-inset);
border: 0;
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.5;
resize: vertical;
outline: 0;
transition: var(--transition-colors);
box-sizing: border-box;
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
@media (max-width: 640px) {
.detail-grid { grid-template-columns: 1fr; }
.check-grid { grid-template-columns: 1fr; }
.number-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -4,9 +4,15 @@ import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi'
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
import { RouterLink } from 'vue-router'
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 Switch from '@/components/ds/forms/Switch.vue'
import Select from '@/components/ds/forms/Select.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const wipeStore = useWipeStore()
const server = useServerStore()
@@ -65,10 +71,32 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
}
}
const WIPE_TYPE_OPTIONS = [
{ value: 'map', label: 'Map' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'full', label: 'Full' },
]
function profileOptions() {
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
for (const p of wipeStore.profiles) {
opts.push({ value: p.id, label: p.profile_name })
}
return opts
}
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'
}
onMounted(async () => {
await wipeStore.fetchProfiles()
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
selectedProfileId.value = wipeStore.profiles[0].id
const first = wipeStore.profiles[0]
if (first) {
selectedProfileId.value = first.id
}
wipeStore.fetchSchedules()
wipeStore.fetchHistory()
@@ -76,220 +104,413 @@ onMounted(async () => {
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<RefreshCw class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Auto-Wiper</h1>
<div class="wipes">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Operations</div>
<h1 class="page__title">Auto-wiper</h1>
</div>
<div class="flex gap-2">
<RouterLink
to="/wipes/profiles"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Profiles
<div class="page__actions">
<RouterLink to="/wipes/profiles">
<Button variant="secondary" size="sm" icon="file-text">Profiles</Button>
</RouterLink>
<RouterLink
to="/wipes/calendar"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Calendar
<RouterLink to="/wipes/calendar">
<Button variant="secondary" size="sm" icon="calendar">Calendar</Button>
</RouterLink>
<RouterLink
to="/wipes/history"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
History
<RouterLink to="/wipes/history">
<Button variant="secondary" size="sm" icon="clock">History</Button>
</RouterLink>
</div>
</div>
<!-- Manual Trigger -->
<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">Manual Wipe</h2>
<div v-if="wipeStore.profiles.length === 0" class="mb-4 flex items-center gap-2 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2">
<AlertTriangle class="w-4 h-4 shrink-0" />
No wipe profiles found. <RouterLink to="/wipes/profiles" class="underline hover:text-yellow-300 ml-1">Create a profile</RouterLink> before triggering a wipe.
</div>
<div class="flex items-end gap-4">
<div>
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['map', 'blueprint', 'full'] as const)"
:key="opt"
@click="triggerType = opt"
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
:class="triggerType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
<!-- Manual trigger -->
<Panel title="Manual wipe" subtitle="Trigger an immediate wipe outside the schedule">
<div class="trigger-body">
<Alert
v-if="wipeStore.profiles.length === 0"
tone="warn"
title="No wipe profiles"
>
<template #default>
Create a profile before triggering a wipe.
</template>
<template #actions>
<RouterLink to="/wipes/profiles">
<Button variant="outline" size="sm" icon="plus">New profile</Button>
</RouterLink>
</template>
</Alert>
<div class="trigger-row">
<!-- Wipe type segment -->
<div class="trigger-field">
<div class="cc-field__label">Wipe type</div>
<div class="type-seg">
<button
v-for="opt in WIPE_TYPE_OPTIONS"
:key="opt.value"
type="button"
class="type-seg__btn"
:class="triggerType === opt.value && 'type-seg__btn--active'"
@click="triggerType = opt.value as 'map' | 'blueprint' | 'full'"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Profile select -->
<Select
label="Profile"
:options="profileOptions()"
:disabled="wipeStore.profiles.length === 0"
:model-value="selectedProfileId"
@update:model-value="selectedProfileId = $event ?? ''"
/>
<div class="trigger-actions">
<Button
variant="secondary"
size="md"
icon="flask-conical"
:loading="dryRunLoading"
:disabled="wipeStore.profiles.length === 0"
@click="triggerDryRun"
>
{{ opt }}
</button>
Dry run
</Button>
<Button
variant="danger"
size="md"
icon="zap"
:loading="triggerLoading"
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
@click="triggerWipe"
>
Trigger wipe
</Button>
</div>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-2">Profile</label>
<select
v-model="selectedProfileId"
:disabled="wipeStore.profiles.length === 0"
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 disabled:opacity-50"
>
<option value="">No profile</option>
<option v-for="profile in wipeStore.profiles" :key="profile.id" :value="profile.id">
{{ profile.profile_name }}
</option>
</select>
</div>
<button
@click="triggerDryRun"
:disabled="dryRunLoading || wipeStore.profiles.length === 0"
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 border border-neutral-700 rounded-lg transition-colors"
>
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
<AlertTriangle v-else class="w-4 h-4" />
Dry Run
</button>
<button
@click="triggerWipe"
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />
<Zap v-else class="w-4 h-4" />
Trigger Wipe
</button>
</div>
</div>
</Panel>
<!-- Dry-Run Results -->
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
<div class="flex items-center gap-3">
<span class="text-xs text-neutral-500">
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
</span>
<button
@click="dryRunResult = null"
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Dry-run results -->
<Panel
v-if="dryRunResult"
title="Dry-run results"
:subtitle="`Estimated duration: ${Math.round(dryRunResult.estimated_duration_seconds)}s`"
>
<template #actions>
<Button variant="ghost" size="sm" icon="x" @click="dryRunResult = null">Dismiss</Button>
</template>
<div class="dry-run-grid">
<div>
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
<X class="w-3.5 h-3.5" />
Would Delete ({{ dryRunResult.would_delete.length }})
</p>
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
<ul v-else class="space-y-1">
<div class="dry-run__head dry-run__head--delete">
Would delete ({{ dryRunResult.would_delete.length }})
</div>
<div v-if="dryRunResult.would_delete.length === 0" class="dry-run__empty">
Nothing to delete
</div>
<ul v-else class="dry-run__list">
<li
v-for="item in dryRunResult.would_delete"
:key="item"
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
class="dry-run__item dry-run__item--delete"
>{{ item }}</li>
</ul>
</div>
<div>
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
<Check class="w-3.5 h-3.5" />
Would Preserve ({{ dryRunResult.would_preserve.length }})
</p>
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
<ul v-else class="space-y-1">
<div class="dry-run__head dry-run__head--keep">
Would preserve ({{ dryRunResult.would_preserve.length }})
</div>
<div v-if="dryRunResult.would_preserve.length === 0" class="dry-run__empty">
Nothing preserved
</div>
<ul v-else class="dry-run__list">
<li
v-for="item in dryRunResult.would_preserve"
:key="item"
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
class="dry-run__item dry-run__item--keep"
>{{ item }}</li>
</ul>
</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-4">Scheduled Wipes</h2>
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 py-4 text-center">
No wipe schedules configured. Create a profile and schedule to automate wipes.
</div>
<div v-else class="space-y-3">
<!-- Scheduled wipes -->
<Panel title="Scheduled wipes" subtitle="Active cron schedules">
<EmptyState
v-if="wipeStore.schedules.length === 0"
icon="calendar-clock"
title="No schedules"
description="Create a profile and schedule to automate wipes."
/>
<div v-else class="sched-list">
<div
v-for="schedule in wipeStore.schedules"
:key="schedule.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
class="sched-row"
>
<div class="flex items-center gap-3">
<Clock class="w-4 h-4 text-neutral-500" />
<div>
<p class="text-sm font-medium text-neutral-200">{{ schedule.schedule_name }}</p>
<p class="text-xs text-neutral-500">
{{ schedule.wipe_type }} wipe &middot; {{ schedule.cron_expression }} ({{ schedule.timezone }})
</p>
<div class="sched-info">
<div class="sched-name">{{ schedule.schedule_name }}</div>
<div class="sched-meta">
{{ schedule.wipe_type }} wipe
&middot;
<span class="mono">{{ schedule.cron_expression }}</span>
&middot; {{ schedule.timezone }}
</div>
</div>
<div class="flex items-center gap-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<div class="sched-controls">
<Badge :tone="schedule.is_active ? 'online' : 'neutral'">
{{ schedule.is_active ? 'Active' : 'Paused' }}
</span>
<button
@click="toggleSchedule(schedule.id, schedule.is_active)"
</Badge>
<Switch
:model-value="schedule.is_active"
:disabled="scheduleToggling === schedule.id"
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
>
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
<div
v-else
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
/>
</button>
@update:model-value="toggleSchedule(schedule.id, schedule.is_active)"
/>
</div>
</div>
</div>
</div>
</Panel>
<!-- Recent History -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Recent Wipes</h2>
<RouterLink to="/wipes/history" class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
View All
<!-- Recent history -->
<Panel :flush-body="true" title="Recent wipes">
<template #actions>
<RouterLink to="/wipes/history">
<Button variant="ghost" size="sm">View all</Button>
</RouterLink>
</div>
<div v-if="wipeStore.history.length === 0" class="text-sm text-neutral-500 py-4 text-center">
No wipe history yet.
</div>
<div v-else class="space-y-2">
<div
v-for="wipe in wipeStore.history.slice(0, 5)"
:key="wipe.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
>
<div>
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p>
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} &middot; {{ safeDate(wipe.started_at, 'Pending') }}</p>
</div>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="{
'bg-green-500/10 text-green-400': wipe.status === 'success',
'bg-red-500/10 text-red-400': wipe.status === 'failed' || wipe.status === 'rolled_back',
'bg-yellow-500/10 text-yellow-400': wipe.status === 'wiping' || wipe.status === 'pre_wipe' || wipe.status === 'post_wipe',
'bg-neutral-700/50 text-neutral-400': wipe.status === 'pending',
}"
</template>
<EmptyState
v-if="wipeStore.history.length === 0"
icon="trash-2"
title="No wipe history"
description="Wipes will appear here once they run."
/>
<table v-else class="cc-table">
<thead>
<tr>
<th>Type</th>
<th>Trigger</th>
<th>Started</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="wipe in wipeStore.history.slice(0, 5)"
:key="wipe.id"
>
{{ wipe.status }}
</span>
</div>
</div>
</div>
<td class="td-primary">{{ wipe.wipe_type }} wipe</td>
<td>{{ wipe.trigger_type }}</td>
<td class="td-mono">{{ safeDate(wipe.started_at, 'Pending') }}</td>
<td>
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status }}</Badge>
</td>
</tr>
</tbody>
</table>
</Panel>
</div>
</template>
<style scoped>
.wipes {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Page head */
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page__title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
margin-top: 4px;
}
.page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.page__actions a { text-decoration: none; }
/* Trigger body */
.trigger-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.trigger-row {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.trigger-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.trigger-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* Wipe type segment */
.type-seg {
display: flex;
background: var(--surface-inset);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
overflow: hidden;
}
.type-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);
}
.type-seg__btn:hover { color: var(--text-primary); background: var(--surface-hover); }
.type-seg__btn--active {
background: var(--accent-soft);
color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Dry-run results */
.dry-run-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.dry-run__head {
font-size: var(--text-xs);
font-weight: 600;
margin-bottom: 8px;
}
.dry-run__head--delete { color: var(--status-offline); }
.dry-run__head--keep { color: var(--status-online); }
.dry-run__empty {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
}
.dry-run__list {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
}
.dry-run__item {
font-family: var(--font-mono);
font-size: var(--text-xs);
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.dry-run__item--delete {
background: var(--status-offline-soft);
color: var(--text-secondary);
box-shadow: inset 0 0 0 1px var(--status-offline-border);
}
.dry-run__item--keep {
background: var(--status-online-soft);
color: var(--text-secondary);
box-shadow: inset 0 0 0 1px var(--status-online-border);
}
/* Schedule list */
.sched-list {
display: flex;
flex-direction: column;
gap: 0;
}
.sched-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 2px;
border-bottom: 1px solid var(--border-subtle);
}
.sched-row:last-child { border-bottom: 0; }
.sched-info { flex: 1; min-width: 0; }
.sched-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
}
.sched-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.sched-controls {
display: flex;
align-items: center;
gap: 10px;
flex: none;
}
.mono { font-family: var(--font-mono); }
/* 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; }
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
@media (max-width: 768px) {
.dry-run-grid { grid-template-columns: 1fr; }
.trigger-row { flex-direction: column; align-items: stretch; }
.trigger-actions { margin-left: 0; }
}
</style>