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, Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog, LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft, LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
Ban, Flag,
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
Pencil, Save, ShoppingBag, Target, User,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const props = withDefaults( const props = withDefaults(
@@ -50,6 +53,11 @@ const registry: Record<string, Component> = {
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama, 'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid, 'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft, '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) const cmp = computed<Component | null>(() => registry[props.name] ?? null)

View File

@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { AlertTriangle, Save, Loader2 } from 'lucide-vue-next'
import { safeDate } from '@/utils/formatters' 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 { interface AlertConfig {
population_drop_enabled: boolean population_drop_enabled: boolean
@@ -62,15 +68,29 @@ async function saveConfig() {
} }
} }
function getSeverityColor(severity: string): string { function severityTone(severity: string): 'info' | 'warn' | 'offline' | 'neutral' {
switch (severity) { if (severity === 'info') return 'info'
case 'info': return 'bg-blue-500/10 text-blue-400' if (severity === 'warning') return 'warn'
case 'warning': return 'bg-yellow-500/10 text-yellow-400' if (severity === 'critical') return 'offline'
case 'critical': return 'bg-red-500/10 text-red-400' return 'neutral'
default: return 'bg-neutral-700/50 text-neutral-400' }
// 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(() => { onMounted(() => {
fetchConfig() fetchConfig()
fetchHistory() fetchHistory()
@@ -78,156 +98,258 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="alerts">
<!-- Header --> <!-- Page head -->
<div class="flex items-center gap-3"> <div class="page__head">
<AlertTriangle class="w-5 h-5 text-oxide-500" /> <div>
<h1 class="text-2xl font-bold text-neutral-100">Alerts</h1> <div class="t-eyebrow">Monitoring</div>
<h1 class="page__title">Alerts</h1>
</div>
</div> </div>
<!-- Alert Configuration --> <!-- Alert configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Alert configuration" subtitle="Trigger conditions and notification channels">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Alert Configuration</h2> <div v-if="isLoading" class="loading-row">
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
<div v-if="isLoading" class="py-8 flex justify-center">
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
</div> </div>
<div v-else class="space-y-6"> <div v-else class="config-body">
<!-- Population Drop Alert --> <!-- Triggers section -->
<div class="space-y-3"> <div class="config-section">
<div class="flex items-center justify-between"> <div class="t-eyebrow config-section__eyebrow">Triggers</div>
<label class="text-sm font-medium text-neutral-200">Population Drop Alert</label>
<button <!-- Population drop -->
@click="config.population_drop_enabled = !config.population_drop_enabled" <div class="alert-rule">
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" <div class="alert-rule__head">
:class="config.population_drop_enabled ? 'bg-oxide-500' : 'bg-neutral-700'" <div>
> <div class="alert-rule__name">Population drop alert</div>
<span <div class="alert-rule__desc">Fire when player count falls by this percentage</div>
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>
<div v-if="config.population_drop_enabled"> <Switch v-model="config.population_drop_enabled" />
<label class="block text-xs text-neutral-500 mb-2">Threshold (%)</label> </div>
<div v-if="config.population_drop_enabled" class="alert-rule__body">
<div class="range-field">
<input <input
v-model.number="config.population_drop_threshold_percent"
type="range" type="range"
:value="config.population_drop_threshold_percent"
min="10" min="10"
max="100" max="100"
class="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-oxide-500" class="cc-range"
@input="onThresholdInput"
/> />
<div class="text-xs text-neutral-400 mt-1">{{ config.population_drop_threshold_percent }}%</div> <span class="range-value">{{ config.population_drop_threshold_percent }}%</span>
</div>
</div> </div>
</div> </div>
<!-- FPS Degradation Alert --> <!-- FPS degradation -->
<div class="space-y-3"> <div class="alert-rule">
<div class="flex items-center justify-between"> <div class="alert-rule__head">
<label class="text-sm font-medium text-neutral-200">FPS Degradation Alert</label> <div>
<button <div class="alert-rule__name">FPS degradation alert</div>
@click="config.fps_degradation_enabled = !config.fps_degradation_enabled" <div class="alert-rule__desc">Fire when server FPS drops below threshold</div>
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>
<div v-if="config.fps_degradation_enabled"> <Switch v-model="config.fps_degradation_enabled" />
<label class="block text-xs text-neutral-500 mb-2">FPS Threshold</label> </div>
<input <div v-if="config.fps_degradation_enabled" class="alert-rule__body">
v-model.number="config.fps_threshold" <Input
:model-value="fpsStr"
label="FPS threshold"
type="number" type="number"
min="10" :mono="true"
max="60" hint="Minimum acceptable FPS (1060)"
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" @update:model-value="onFpsUpdate"
/> />
</div> </div>
</div> </div>
</div>
<!-- Notification Channels --> <!-- Notification channels -->
<div class="border-t border-neutral-800 pt-4"> <div class="config-section">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Notification Channels</h3> <div class="t-eyebrow config-section__eyebrow">Notification channels</div>
<div class="space-y-2"> <div class="channels">
<label class="flex items-center gap-3 cursor-pointer"> <Checkbox v-model="config.notify_discord" label="Discord" />
<input <Checkbox v-model="config.notify_pushbullet" label="Pushbullet" />
v-model="config.notify_discord" <Checkbox v-model="config.notify_email" label="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">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>
</div> </div>
<!-- Save Button --> <!-- Save -->
<button <Button icon="save" :loading="isSaving" @click="saveConfig">
@click="saveConfig" Save configuration
:disabled="isSaving" </Button>
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>
</div> </div>
</Panel>
<!-- Alert History --> <!-- Alert history -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel :flush-body="true" title="Alert history">
<div class="p-5 border-b border-neutral-800"> <EmptyState
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Alert History</h2> v-if="history.length === 0"
</div> icon="triangle-alert"
<div v-if="history.length === 0" class="p-8 text-center text-neutral-500"> title="No alerts triggered"
No alerts triggered yet. description="Alerts will appear here when trigger conditions are met."
</div> />
<table v-else class="w-full"> <table v-else class="cc-table">
<thead class="bg-neutral-800/50 border-b border-neutral-800"> <thead>
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Triggered</th> <th>Triggered</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th> <th>Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Severity</th> <th>Severity</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Title</th> <th>Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Message</th> <th>Message</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30"> <tr v-for="entry in history" :key="entry.id">
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td> <td class="td-mono">{{ safeDate(entry.triggered_at) }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td> <td>{{ entry.alert_type }}</td>
<td class="px-4 py-3"> <td>
<span <Badge :tone="severityTone(entry.severity)">{{ entry.severity }}</Badge>
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
:class="getSeverityColor(alert.severity)"
>
{{ alert.severity }}
</span>
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-200">{{ alert.title }}</td> <td class="td-primary">{{ entry.title }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.message }}</td> <td>{{ entry.message }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Panel>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
@@ -8,6 +7,11 @@ import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { AnalyticsSummary, TimeseriesData } from '@/types' import type { AnalyticsSummary, TimeseriesData } from '@/types'
import { safeFixed } from '@/utils/formatters' 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 api = useApi()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -54,9 +58,22 @@ const loadAnalytics = async () => {
} }
} }
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => { const renderCharts = () => {
if (!timeseries.value) return 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 // Player count chart
if (playerChart.value) { if (playerChart.value) {
if (playerChartInstance) { if (playerChartInstance) {
@@ -68,9 +85,9 @@ const renderCharts = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' } textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
}, },
grid: { grid: {
left: '3%', left: '3%',
@@ -86,14 +103,14 @@ const renderCharts = () => {
day: 'numeric', day: 'numeric',
hour: '2-digit' hour: '2-digit'
})), })),
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
series: [ series: [
{ {
@@ -101,14 +118,14 @@ const renderCharts = () => {
type: 'line', type: 'line',
data: timeseries.value.player_count, data: timeseries.value.player_count,
smooth: true, smooth: true,
lineStyle: { color: '#CE422B', width: 2 }, lineStyle: { color: accent, width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(206, 66, 43, 0.3)' }, { offset: 0, color: accent + '55' },
{ offset: 1, color: 'rgba(206, 66, 43, 0)' } { offset: 1, color: accent + '00' }
]) ])
}, },
itemStyle: { color: '#CE422B' } itemStyle: { color: accent }
} }
] ]
}) })
@@ -125,13 +142,13 @@ const renderCharts = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' } textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
}, },
legend: { legend: {
data: ['FPS', 'Entities'], data: ['FPS', 'Entities'],
textStyle: { color: '#a3a3a3' }, textStyle: { color: labelColor, fontFamily: mono },
top: 0 top: 0
}, },
grid: { grid: {
@@ -148,25 +165,25 @@ const renderCharts = () => {
day: 'numeric', day: 'numeric',
hour: '2-digit' hour: '2-digit'
})), })),
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
name: 'FPS', name: 'FPS',
position: 'left', position: 'left',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
{ {
type: 'value', type: 'value',
name: 'Entities', name: 'Entities',
position: 'right', position: 'right',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { show: false }, splitLine: { show: false },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
} }
], ],
series: [ series: [
@@ -223,114 +240,209 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="analytics-view">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="analytics-view__header">
<div class="flex items-center gap-3"> <h1 class="analytics-view__title">Analytics</h1>
<BarChart3 class="w-5 h-5 text-oxide-500" /> <div class="analytics-view__controls">
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1> <Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
</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" />
Export CSV Export CSV
</button> </Button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden"> <Tabs
<button :items="(['24h', '7d', '30d'] as const).map(v => ({ value: v, label: v }))"
v-for="opt in (['24h', '7d', '30d'] as const)" v-model="timeRange"
:key="opt" variant="pill"
@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>
</div> </div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="analytics-view__loading">
<div class="text-neutral-500">Loading analytics...</div> <span class="analytics-view__loading-text">Loading analytics...</span>
</div> </div>
<template v-else-if="summary"> <template v-else-if="summary">
<!-- Stat cards --> <!-- Stat cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="analytics-view__stats">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Peak players"
<Users class="w-4 h-4 text-neutral-500" /> :value="summary.peak_players ?? '—'"
<p class="text-sm text-neutral-400">Peak Players</p> icon="users"
</div> :note="`Last ${timeRange}`"
<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> <StatCard
</div> label="Avg players"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> :value="safeFixed(summary?.avg_players, 1)"
<div class="flex items-center gap-2 mb-2"> icon="trending-up"
<TrendingUp class="w-4 h-4 text-neutral-500" /> :note="`Last ${timeRange}`"
<p class="text-sm text-neutral-400">Avg Players</p> />
</div> <StatCard
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p> label="Uptime"
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p> :value="safeFixed(summary?.uptime_percentage, 1)"
</div> unit="%"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> icon="clock"
<div class="flex items-center gap-2 mb-2"> :note="`Last ${timeRange}`"
<Clock class="w-4 h-4 text-neutral-500" /> />
<p class="text-sm text-neutral-400">Uptime</p> <StatCard
</div> label="Unique players"
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p> :value="summary.unique_players ?? '—'"
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p> icon="bar-chart-3"
</div> note="Phase 2.2"
<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> </div>
<!-- Charts --> <!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="analytics-view__charts">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Player count over time">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Count Over Time</h2> <div ref="playerChart" class="analytics-view__chart-area"></div>
<div ref="playerChart" class="h-64"></div> </Panel>
</div> <Panel title="Server performance">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <div ref="perfChart" class="analytics-view__chart-area"></div>
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Server Performance</h2> </Panel>
<div ref="perfChart" class="h-64"></div>
</div>
</div> </div>
<!-- Player Retention --> <!-- Player Retention placeholder -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel eyebrow="Coming in phase 2" title="Player retention">
<div class="flex items-center justify-between mb-4"> <template #title-append>
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2> <Badge tone="neutral">Phase 2</Badge>
<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> </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>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div class="analytics-view__retention-cell">
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800"> <p class="analytics-view__retention-label">Returning players</p>
<p class="text-xs text-neutral-500 mb-1">New Players</p> <p class="analytics-view__retention-value"></p>
<p class="text-2xl font-bold text-neutral-600">\u2014</p> <p class="analytics-view__retention-note">Seen more than once</p>
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
</div> </div>
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800"> <div class="analytics-view__retention-cell">
<p class="text-xs text-neutral-500 mb-1">Returning Players</p> <p class="analytics-view__retention-label">Avg session duration</p>
<p class="text-2xl font-bold text-neutral-600">\u2014</p> <p class="analytics-view__retention-value"></p>
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p> <p class="analytics-view__retention-note">Per visit</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> </div>
</div> </div>
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p> <p class="analytics-view__retention-footer">
</div> Player retention analytics will be available in phase 2.
</p>
</Panel>
</template> </template>
</div> </div>
</template> </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 { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { ChatMessage } from '@/types' 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 api = useApi()
const toast = useToastStore() const toast = useToastStore()
@@ -32,12 +39,18 @@ const filteredMessages = computed(() => {
return result 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) { switch (channel) {
case 'global': return 'bg-oxide-500/15 text-oxide-400' case 'global': return 'accent'
case 'team': return 'bg-blue-500/15 text-blue-400' case 'team': return 'info'
case 'server': return 'bg-neutral-700/50 text-neutral-400' default: return 'neutral'
default: return 'bg-neutral-700/50 text-neutral-400'
} }
} }
@@ -76,89 +89,163 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="clv">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="clv__head">
<div class="flex items-center gap-3"> <div class="clv__head-id">
<MessageSquare class="w-5 h-5 text-oxide-500" /> <div class="clv__head-chip">
<Icon name="message-square" :size="20" :stroke-width="2" />
</div>
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Chat Log</h1> <div class="t-eyebrow">Chat log</div>
<p class="text-sm text-neutral-500 mt-0.5">{{ messages.length }} messages</p> <h1 class="clv__title">Chat log</h1>
</div> </div>
</div> </div>
<button <div class="clv__head-actions">
@click="fetchMessages" <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" :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" @click="fetchMessages"
> >Refresh</Button>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" /> </div>
Refresh
</button>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="flex items-center gap-4"> <div class="clv__filters">
<div class="relative flex-1 max-w-sm"> <Input
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery" v-model="searchQuery"
type="text" icon="search"
placeholder="Search messages, players, or Steam IDs..." 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" size="sm"
style="max-width: 340px;"
/> />
</div> <Tabs v-model="channelFilter" :items="channelTabItems" />
<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> </div>
<!-- Messages --> <!-- Messages panel -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg divide-y divide-neutral-800"> <Panel :flush-body="true">
<div v-if="filteredMessages.length === 0" class="px-4 py-12 text-center text-neutral-500 text-sm"> <!-- Empty state -->
<template v-if="isLoading">Loading chat messages...</template> <EmptyState
<template v-else-if="searchQuery">No messages matching "{{ searchQuery }}"</template> v-if="filteredMessages.length === 0 && !isLoading"
<template v-else>No chat messages yet. Messages will appear when the server is active.</template> 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>
<!-- Message list -->
<div v-else class="clv__messages">
<div <div
v-for="msg in filteredMessages" v-for="msg in filteredMessages"
:key="msg.id" :key="msg.id"
class="flex items-start gap-4 px-4 py-3 hover:bg-neutral-800/50 transition-colors" class="clv__row"
:class="{ 'bg-red-500/5 border-l-2 border-l-red-500/30': msg.flagged }" :class="{ 'clv__row--flagged': msg.flagged }"
> >
<div class="shrink-0 text-right w-20"> <!-- Timestamp -->
<p class="text-xs text-neutral-500">{{ formatDate(msg.created_at) }}</p> <div class="clv__ts">
<p class="text-xs text-neutral-600">{{ formatTime(msg.created_at) }}</p> <span class="clv__date">{{ formatDate(msg.created_at) }}</span>
<span class="clv__time">{{ formatTime(msg.created_at) }}</span>
</div> </div>
<span
class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full mt-0.5" <!-- Channel badge -->
:class="channelBadgeClass(msg.channel)" <Badge :tone="channelTone(msg.channel)" size="md">{{ msg.channel }}</Badge>
>
{{ msg.channel }} <!-- Message body -->
</span> <div class="clv__body">
<div class="flex-1 min-w-0"> <span class="clv__player">{{ msg.player_name }}</span>
<span class="text-sm font-medium text-oxide-400">{{ msg.player_name }}</span> <span class="clv__steam">{{ msg.steam_id }}</span>
<span class="text-sm text-neutral-500 ml-2 font-mono">{{ msg.steam_id }}</span> <p class="clv__text">{{ msg.message }}</p>
<p class="text-sm text-neutral-300 mt-0.5 break-words">{{ msg.message }}</p>
</div> </div>
<button
<!-- Flag toggle -->
<IconButton
icon="bookmark"
:variant="msg.flagged ? 'accent' : 'ghost'"
size="sm"
:label="msg.flagged ? 'Unflag message' : 'Flag message'"
@click="toggleFlag(msg)" @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>
</div> </div>
</Panel>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick, computed } from 'vue' import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import type { MapAnalyticsSummary } from '@/types' import type { MapAnalyticsSummary } from '@/types'
import { safeFixed } from '@/utils/formatters' 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() const api = useApi()
@@ -30,6 +36,10 @@ const loadMapAnalytics = async () => {
} }
} }
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => { const renderCharts = () => {
if (!analytics.value || analytics.value.maps.length === 0) return 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 avgPlayers = analytics.value.maps.map(m => m.avg_players)
const peakPlayers = analytics.value.maps.map(m => m.peak_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({ performanceChartInstance.setOption({
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' }, textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
axisPointer: { axisPointer: {
type: 'shadow' type: 'shadow'
} }
}, },
legend: { legend: {
data: ['Avg Players', 'Peak Players'], data: ['Avg Players', 'Peak Players'],
textStyle: { color: '#a3a3a3' }, textStyle: { color: labelColor, fontFamily: mono },
top: 0 top: 0
}, },
grid: { grid: {
@@ -70,22 +89,22 @@ const renderCharts = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: mapNames, data: mapNames,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'Players', name: 'Players',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
series: [ series: [
{ {
name: 'Avg Players', name: 'Avg Players',
type: 'bar', type: 'bar',
data: avgPlayers, data: avgPlayers,
itemStyle: { color: '#CE422B' }, itemStyle: { color: accent },
barGap: '10%' barGap: '10%'
}, },
{ {
@@ -138,182 +157,263 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="map-analytics-view">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="map-analytics-view__header">
<div class="flex items-center gap-3"> <h1 class="map-analytics-view__title">Map analytics</h1>
<Map class="w-5 h-5 text-oxide-500" /> <div class="map-analytics-view__controls">
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1> <Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
</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" />
Export CSV Export CSV
</button> </Button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden"> <Tabs
<button :items="[
v-for="opt in (['30d', '90d', 'all'] as const)" { value: '30d', label: '30d' },
:key="opt" { value: '90d', label: '90d' },
@click="timeRange = opt" { value: 'all', label: 'All' }
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'" v-model="timeRange"
> variant="pill"
{{ opt }} />
</button>
</div>
</div> </div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="map-analytics-view__loading">
<div class="text-neutral-500">Loading map analytics...</div> <span class="map-analytics-view__loading-text">Loading map analytics...</span>
</div> </div>
<template v-else-if="analytics"> <template v-else-if="analytics">
<!-- Summary cards --> <!-- Summary cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="map-analytics-view__stats">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Best performing map"
<Award class="w-4 h-4 text-oxide-400" /> :value="analytics.best_performing_map ?? 'No data'"
<p class="text-sm text-neutral-400">Best Performing Map</p> icon="award"
</div> :note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
<p class="text-xl font-bold text-neutral-100"> />
{{ analytics.best_performing_map ?? 'No data' }} <StatCard
</p> label="Rotation effectiveness"
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0"> :value="safeFixed(analytics?.rotation_effectiveness, 1)"
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players unit="%"
</p> icon="target"
</div> note="Overall rotation health"
/>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Total maps tracked"
<Target class="w-4 h-4 text-green-400" /> :value="analytics.maps.length"
<p class="text-sm text-neutral-400">Rotation Effectiveness</p> icon="trending-up"
</div> :note="`Last ${timeRange}`"
<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> </div>
<!-- Performance chart --> <!-- Performance chart -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Map performance comparison">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4"> <div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
Map Performance Comparison <EmptyState
</h2> v-else
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div> icon="map"
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg"> title="No map data"
<p class="text-sm text-neutral-600">No map data available for this time range</p> description="No map data available for this time range."
</div> />
</div> </Panel>
<!-- Map performance table --> <!-- Map performance table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4"> <div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
Detailed Map Metrics <table class="map-analytics-view__table">
</h2> <thead>
<div v-if="sortedMaps.length > 0" class="overflow-x-auto"> <tr class="map-analytics-view__thead-row">
<table class="w-full text-sm"> <th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
<thead class="border-b border-neutral-800"> <th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
<tr> <th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th> <th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th> <th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th> <th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="map in sortedMaps" v-for="map in sortedMaps"
:key="map.map_id" :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="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td> <td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td> <td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ safeFixed(map.avg_players, 1) }}</td> <td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td> <td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
<td class="py-3 px-4 text-right"> <td class="map-analytics-view__td map-analytics-view__td--right">
<span <Badge
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium" :tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
:class="{ mono
'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
}"
> >
{{ safeFixed(map.effectiveness_score, 1) }}% {{ safeFixed(map.effectiveness_score, 1) }}%
</span> </Badge>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg"> <EmptyState
<p class="text-sm text-neutral-600">No map data available</p> v-else
</div> icon="map"
</div> title="No map data"
description="No map data available."
/>
</Panel>
<!-- Insights section --> <!-- Insights section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Actionable insights">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4"> <div class="map-analytics-view__insights">
Actionable Insights <Alert
</h2> v-if="analytics.best_performing_map"
<div class="space-y-3"> tone="accent"
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg"> :title="`Best map: ${analytics.best_performing_map}`"
<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. Consider featuring this map more frequently in your rotation for maximum player engagement.
</p> </Alert>
</div>
</div>
<div <Alert
v-if="analytics.rotation_effectiveness < 70" 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. Consider removing low-performing maps (effectiveness &lt; 60%) and testing new maps to improve overall rotation health.
</p> </Alert>
</div>
</div>
<div <Alert
v-else-if="analytics.rotation_effectiveness >= 80" 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. Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
</p> </Alert>
</div>
</div>
</div>
</div> </div>
</Panel>
</template> </template>
</div> </div>
</template> </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 { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { MapEntry } from '@/types' import type { MapEntry } from '@/types'
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { safeFileSize } from '@/utils/formatters' 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 api = useApi()
const auth = useAuthStore() const auth = useAuthStore()
@@ -20,8 +24,8 @@ function formatSize(bytes: number): string {
return safeFileSize(bytes) return safeFileSize(bytes)
} }
function typeBadgeClass(type: string): string { function mapTypeTone(type: string): 'accent' | 'info' {
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400' return type === 'custom' ? 'accent' : 'info'
} }
async function fetchMaps() { async function fetchMaps() {
@@ -92,84 +96,211 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="maps">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3">
<Map class="w-5 h-5 text-oxide-500" />
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1> <div class="t-eyebrow">Operations</div>
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p> <h1 class="page__title">
Map library
<span v-if="maps.length > 0" class="page__count">{{ maps.length }} maps</span>
</h1>
</div> </div>
</div> <div class="page__actions">
<div class="flex items-center gap-3"> <IconButton
<button icon="refresh-cw"
@click="fetchMaps" label="Refresh"
:class="isLoading && 'spin'"
:disabled="isLoading" :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" @click="fetchMaps"
> />
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</button>
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept=".map" accept=".map"
class="hidden" class="hidden-input"
@change="handleFileSelected" @change="handleFileSelected"
/> />
<button <Button
icon="upload"
:loading="isUploading"
@click="triggerFileInput" @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" /> {{ isUploading ? 'Uploading…' : 'Upload map' }}
<Upload v-else class="w-4 h-4" /> </Button>
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
</button>
</div> </div>
</div> </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>
<!-- Maps grid --> <!-- Maps grid -->
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"> <div v-else class="maps-grid">
<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>
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
<div <div
v-for="map in maps" v-for="map in maps"
:key="map.id" :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 --> <!-- Thumbnail -->
<div class="h-32 bg-neutral-800 flex items-center justify-center"> <div class="map-card__thumb">
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" /> <img
<Map v-else class="w-8 h-8 text-neutral-600" /> 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>
<div class="p-4">
<div class="flex items-center justify-between mb-2"> <!-- Card body -->
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3> <div class="map-card__body">
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)"> <div class="map-card__row">
{{ map.map_type }} <span class="map-card__name">{{ map.display_name }}</span>
</span> <Badge :tone="mapTypeTone(map.map_type)" size="md">{{ map.map_type }}</Badge>
</div> </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>{{ formatSize(map.file_size_bytes) }}</span>
<span v-if="map.world_size">{{ map.world_size }}m</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>
<div class="flex justify-end mt-3"> <div class="map-card__foot">
<button <IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Delete map"
@click="deleteMap(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> </div>
</div> </div>
</div> </div>
</template> </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 { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { Module } from '@/types' import type { Module } from '@/types'
import { ShoppingCart, Package, Search, Filter, X, Check, Download, AlertCircle } from 'lucide-vue-next'
import { safeFixed } from '@/utils/formatters' 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 api = useApi()
const auth = useAuthStore() const auth = useAuthStore()
@@ -22,17 +31,22 @@ const isPurchasing = ref(false)
const purchaseError = ref('') const purchaseError = ref('')
const categories = [ const categories = [
{ value: 'all', label: 'All Modules' }, { value: 'all', label: 'All modules' },
{ value: 'loot', label: 'Loot' }, { value: 'loot', label: 'Loot' },
{ value: 'events', label: 'Events' }, { value: 'events', label: 'Events' },
{ value: 'economy', label: 'Economy' }, { value: 'economy', label: 'Economy' },
{ value: 'kits', label: 'Kits' }, { value: 'kits', label: 'Kits' },
{ value: 'admin', label: 'Admin Tools' }, { value: 'admin', label: 'Admin tools' },
{ value: 'pvp', label: 'PVP' }, { value: 'pvp', label: 'PVP' },
{ value: 'pve', label: 'PVE' }, { value: 'pve', label: 'PVE' },
{ value: 'building', label: 'Building' }, { 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(() => { const filteredModules = computed(() => {
let result = activeTab.value === 'catalog' ? modules.value : myModules.value let result = activeTab.value === 'catalog' ? modules.value : myModules.value
@@ -51,6 +65,21 @@ const filteredModules = computed(() => {
return result 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() { async function loadCatalog() {
isLoading.value = true isLoading.value = true
try { try {
@@ -94,10 +123,8 @@ async function confirmPurchase() {
}) })
if (response.payment_url) { if (response.payment_url) {
// Redirect to external payment provider
window.location.href = response.payment_url window.location.href = response.payment_url
} else if (response.success) { } else if (response.success) {
// Instant purchase confirmed
showPurchaseModal.value = false showPurchaseModal.value = false
selectedModule.value = null selectedModule.value = null
await loadCatalog() await loadCatalog()
@@ -131,20 +158,6 @@ function closeModals() {
purchaseError.value = '' 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(() => { onMounted(() => {
loadCatalog() loadCatalog()
loadMyModules() loadMyModules()
@@ -152,323 +165,371 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="ms-page">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3">
<ShoppingCart class="w-5 h-5 text-oxide-500" />
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Module Store</h1> <div class="t-eyebrow">Management</div>
<p class="text-sm text-neutral-500 mt-0.5"> <h1 class="page__title">Module store</h1>
Extend your server with premium gameplay modules <p class="page__sub">Extend your server with premium gameplay modules.</p>
</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> </div>
<Tabs v-model="activeTab" :items="tabItems" />
</div> </div>
<!-- Filters --> <!-- Filters bar -->
<div class="flex items-center gap-4"> <div class="ms-filters">
<div class="relative flex-1 max-w-md"> <Input
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery" v-model="searchQuery"
type="text" icon="search"
placeholder="Search modules..." 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" class="ms-filters__search"
/> />
</div> <Select
<div class="flex items-center gap-2">
<Filter class="w-4 h-4 text-neutral-500" />
<select
v-model="selectedCategory" 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" :options="categories"
> size="md"
<option v-for="cat in categories" :key="cat.value" :value="cat.value"> class="ms-filters__cat"
{{ cat.label }} />
</option>
</select>
</div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-12"> <div v-if="isLoading" class="ms-loading">
<div class="text-neutral-500">Loading modules...</div> <EmptyState icon="loader" title="Loading modules…" />
</div> </div>
<!-- Module grid --> <!-- 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 v-else-if="filteredModules.length > 0" class="ms-grid">
<div <article
v-for="module in filteredModules" v-for="module in filteredModules"
:key="module.id" :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 --> <!-- Preview image -->
<div class="relative h-40 bg-neutral-800 overflow-hidden"> <div class="ms-card__img">
<img <img
v-if="module.preview_image_url" v-if="module.preview_image_url"
:src="module.preview_image_url" :src="module.preview_image_url"
:alt="module.name" :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"> <div v-else class="ms-card__img-placeholder">
<Package class="w-12 h-12 text-neutral-700" /> <Icon name="package" :size="36" :stroke-width="1.5" />
</div> </div>
<div class="absolute top-2 right-2"> <div class="ms-card__img-badges">
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(module.category)"> <Badge :tone="categoryTone(module.category)" size="md">{{ module.category }}</Badge>
{{ module.category }}
</span>
</div> </div>
<div v-if="module.is_purchased" class="absolute top-2 left-2"> <div v-if="module.is_purchased" class="ms-card__img-owned">
<span class="text-xs font-medium px-2 py-1 rounded-full bg-green-500/20 text-green-400 flex items-center gap-1"> <Badge tone="online" icon="check" size="md">Purchased</Badge>
<Check class="w-3 h-3" />
Purchased
</span>
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="p-4 space-y-3"> <div class="ms-card__body">
<div> <div class="ms-card__header">
<div class="flex items-start justify-between gap-2 mb-1"> <span class="ms-card__name">{{ module.name }}</span>
<h3 class="text-base font-semibold text-neutral-100">{{ module.name }}</h3> <span class="ms-price">${{ safeFixed(module.price, 2) }}</span>
<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> </div>
<p class="ms-card__desc">{{ module.description }}</p>
<!-- Features --> <!-- Features -->
<div class="flex flex-wrap gap-1.5"> <div class="ms-card__features">
<span <Badge
v-for="(feature, idx) in module.features.slice(0, 3)" v-for="(feature, idx) in module.features.slice(0, 3)"
:key="idx" :key="idx"
class="text-xs text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded" tone="neutral"
> >{{ feature }}</Badge>
{{ feature }} <span v-if="module.features.length > 3" class="ms-card__more">+{{ module.features.length - 3 }} more</span>
</span>
<span v-if="module.features.length > 3" class="text-xs text-neutral-500">
+{{ module.features.length - 3 }} more
</span>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2 pt-2"> <div class="ms-card__actions">
<button <Button variant="secondary" size="sm" :block="false" @click="openDetailModal(module)">Details</Button>
@click="openDetailModal(module)" <Button
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
v-if="!module.is_purchased" v-if="!module.is_purchased"
size="sm"
@click="initiatePurchase(module)" @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" v-else-if="!module.is_installed"
size="sm"
variant="outline"
icon="download"
@click="installModule(module)" @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" >Install</Button>
> <Button
<Download class="w-3.5 h-3.5" />
Install
</button>
<button
v-else v-else
disabled size="sm"
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" variant="outline"
> icon="check"
<Check class="w-3.5 h-3.5" /> :disabled="true"
Installed >Installed</Button>
</button>
</div>
</div> </div>
</div> </div>
</article>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"> <Panel v-else>
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-3" /> <EmptyState
<h3 class="text-lg font-medium text-neutral-300 mb-1"> icon="package"
{{ activeTab === 'catalog' ? 'No modules found' : 'No purchased modules' }} :title="activeTab === 'catalog' ? 'No modules found' : 'No purchased modules'"
</h3> :description="activeTab === 'catalog' ? 'Try adjusting your search or category filter.' : 'Browse the catalog to purchase modules.'"
<p class="text-sm text-neutral-500"> />
{{ activeTab === 'catalog' ? 'Try adjusting your filters.' : 'Browse the catalog to purchase modules.' }} </Panel>
</p>
</div>
<!-- Detail Modal --> <!-- ===== Detail modal ===== -->
<div <div
v-if="showDetailModal && selectedModule" 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" @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"> <div class="ms-modal ms-modal--wide">
<!-- Modal Header --> <div class="ms-modal__head ms-modal__head--sticky">
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-start justify-between"> <div class="ms-modal__head-content">
<div class="flex-1"> <div class="ms-modal__title-row">
<div class="flex items-center gap-3 mb-2"> <h2 class="ms-modal__title">{{ selectedModule.name }}</h2>
<h2 class="text-xl font-bold text-neutral-100">{{ selectedModule.name }}</h2> <Badge :tone="categoryTone(selectedModule.category)" size="md">{{ selectedModule.category }}</Badge>
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(selectedModule.category)">
{{ selectedModule.category }}
</span>
</div> </div>
<p class="text-sm text-neutral-400">Version {{ selectedModule.version }}</p> <p class="ms-modal__ver">Version {{ selectedModule.version }}</p>
</div> </div>
<button <IconButton icon="x" label="Close" @click="closeModals" />
@click="closeModals"
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
>
<X class="w-5 h-5" />
</button>
</div> </div>
<!-- Modal Body --> <div class="ms-modal__body">
<div class="p-6 space-y-6">
<!-- Screenshots --> <!-- Screenshots -->
<div v-if="selectedModule.screenshots.length > 0" class="space-y-3"> <div v-if="selectedModule.screenshots.length > 0" class="ms-detail-section">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Screenshots</h3> <div class="ms-detail-section__label">Screenshots</div>
<div class="grid grid-cols-2 gap-3"> <div class="ms-screenshots">
<img <img
v-for="(screenshot, idx) in selectedModule.screenshots" v-for="(screenshot, idx) in selectedModule.screenshots"
:key="idx" :key="idx"
:src="screenshot" :src="screenshot"
:alt="`Screenshot ${idx + 1}`" :alt="`Screenshot ${idx + 1}`"
class="w-full h-48 object-cover rounded-lg border border-neutral-800" class="ms-screenshot"
/> />
</div> </div>
</div> </div>
<!-- Description --> <!-- Description -->
<div class="space-y-3"> <div class="ms-detail-section">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Description</h3> <div class="ms-detail-section__label">Description</div>
<p class="text-sm text-neutral-400 leading-relaxed">{{ selectedModule.description }}</p> <p class="ms-detail-desc">{{ selectedModule.description }}</p>
</div> </div>
<!-- Features --> <!-- Features -->
<div class="space-y-3"> <div class="ms-detail-section">
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Features</h3> <div class="ms-detail-section__label">Features</div>
<ul class="space-y-2"> <ul class="ms-features">
<li <li v-for="(feature, idx) in selectedModule.features" :key="idx" class="ms-feature">
v-for="(feature, idx) in selectedModule.features" <Icon name="check" :size="14" :stroke-width="2.5" class="ms-feature__icon" />
: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" />
{{ feature }} {{ feature }}
</li> </li>
</ul> </ul>
</div> </div>
<!-- Price and Purchase --> <!-- Price and action -->
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-center justify-between"> <div class="ms-detail-cta">
<div> <div>
<p class="text-sm text-neutral-400 mb-1">One-time purchase</p> <p class="ms-detail-cta__label">One-time purchase</p>
<p class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</p> <p class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</p>
</div> </div>
<button <Button
v-if="!selectedModule.is_purchased" v-if="!selectedModule.is_purchased"
@click="initiatePurchase(selectedModule)" @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" v-else-if="!selectedModule.is_installed"
variant="outline"
icon="download"
@click="installModule(selectedModule)" @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" >Install</Button>
> <div v-else class="ms-installed">
<Download class="w-4 h-4" /> <Icon name="check" :size="18" :stroke-width="2.5" />
Install <span>Installed</span>
</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>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Purchase Confirmation Modal --> <!-- ===== Purchase confirmation modal ===== -->
<div <div
v-if="showPurchaseModal && selectedModule" 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" @click.self="closeModals"
> >
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-md w-full"> <div class="ms-modal">
<!-- Header --> <div class="ms-modal__head">
<div class="border-b border-neutral-800 px-6 py-4"> <h2 class="ms-modal__title">Confirm purchase</h2>
<h2 class="text-xl font-bold text-neutral-100">Confirm Purchase</h2> <IconButton icon="x" label="Close" @click="closeModals" />
</div> </div>
<!-- Body --> <div class="ms-modal__body">
<div class="p-6 space-y-4"> <div class="ms-purchase-summary">
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 space-y-2"> <div class="ms-purchase-row">
<div class="flex items-center justify-between"> <span class="ms-purchase-row__label">Module</span>
<span class="text-sm text-neutral-400">Module</span> <span class="ms-purchase-row__value">{{ selectedModule.name }}</span>
<span class="text-sm font-medium text-neutral-100">{{ selectedModule.name }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="ms-purchase-row">
<span class="text-sm text-neutral-400">License</span> <span class="ms-purchase-row__label">License</span>
<span class="text-sm font-medium text-neutral-100">{{ auth.license?.license_key }}</span> <span class="ms-purchase-row__value ms-mono">{{ auth.license?.license_key }}</span>
</div> </div>
<div class="border-t border-neutral-700 pt-2 mt-2 flex items-center justify-between"> <div class="ms-purchase-divider" />
<span class="text-base font-medium text-neutral-300">Total</span> <div class="ms-purchase-row ms-purchase-row--total">
<span class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</span> <span class="ms-purchase-row__label">Total</span>
<span class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</span>
</div> </div>
</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"> <Alert v-if="purchaseError" tone="danger">{{ purchaseError }}</Alert>
<AlertCircle class="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<p class="text-sm text-red-400">{{ purchaseError }}</p>
</div>
<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. By confirming this purchase, you agree to the module license terms. This purchase is non-refundable once the module is installed.
</p> </p>
</div> </div>
<!-- Footer --> <div class="ms-modal__foot">
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3"> <Button variant="secondary" :disabled="isPurchasing" @click="closeModals">Cancel</Button>
<button <Button :loading="isPurchasing" @click="confirmPurchase">Confirm purchase</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> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { safeFixed } from '@/utils/formatters' 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 api = useApi()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -60,6 +64,10 @@ const loadRetentionData = async () => {
} }
} }
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => { const renderCharts = () => {
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return 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 retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_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({ retentionChartInstance.setOption({
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' }, textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => { formatter: (params: any) => {
let tooltip = `<strong>${params[0].axisValue}</strong><br/>` let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
params.forEach((param: any) => { params.forEach((param: any) => {
@@ -98,7 +115,7 @@ const renderCharts = () => {
}, },
legend: { legend: {
data: ['24h Return', '48h Return', '72h Return'], data: ['24h Return', '48h Return', '72h Return'],
textStyle: { color: '#a3a3a3' }, textStyle: { color: labelColor, fontFamily: mono },
top: 0 top: 0
}, },
grid: { grid: {
@@ -111,16 +128,18 @@ const renderCharts = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: wipeLabels, data: wipeLabels,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'Retention %', name: 'Retention %',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { axisLabel: {
color: '#808080', color: labelColor,
fontFamily: mono,
fontSize: 10,
formatter: (value: number) => `${value}%` formatter: (value: number) => `${value}%`
}, },
max: 100 max: 100
@@ -131,8 +150,8 @@ const renderCharts = () => {
type: 'line', type: 'line',
data: retention24h, data: retention24h,
smooth: true, smooth: true,
lineStyle: { color: '#CE422B', width: 3 }, lineStyle: { color: accent, width: 3 },
itemStyle: { color: '#CE422B' }, itemStyle: { color: accent },
symbolSize: 8 symbolSize: 8
}, },
{ {
@@ -187,117 +206,91 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="retention-view">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="retention-view__header">
<div class="flex items-center gap-3"> <h1 class="retention-view__title">Player retention</h1>
<Users class="w-5 h-5 text-oxide-500" /> <div class="retention-view__controls">
<h1 class="text-2xl font-bold text-neutral-100">Player Retention</h1> <Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
</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" />
Export CSV Export CSV
</button> </Button>
<div class="flex items-center gap-2 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2"> <Select
<label class="text-sm text-neutral-400">Wipes:</label> :options="[
<select { value: '3', label: 'Last 3 wipes' },
v-model.number="wipeCount" { value: '6', label: 'Last 6 wipes' },
class="bg-transparent text-neutral-100 text-sm focus:outline-none" { value: '10', label: 'Last 10 wipes' },
> { value: '20', label: 'Last 20 wipes' }
<option :value="3">Last 3</option> ]"
<option :value="6">Last 6</option> :model-value="String(wipeCount)"
<option :value="10">Last 10</option> size="sm"
<option :value="20">Last 20</option> @update:model-value="wipeCount = Number($event)"
</select> />
</div>
</div> </div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="retention-view__loading">
<div class="text-neutral-500">Loading retention data...</div> <span class="retention-view__loading-text">Loading retention data...</span>
</div> </div>
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0"> <template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
<!-- Summary cards --> <!-- Summary cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="retention-view__stats">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Unique players"
<Users class="w-4 h-4 text-neutral-500" /> :value="retentionData.summary.unique_players"
<p class="text-sm text-neutral-400">Unique Players</p> icon="users"
</div> note="Last 30 days"
<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> <StatCard
</div> label="Avg session"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> :value="safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0)"
<div class="flex items-center gap-2 mb-2"> unit="m"
<Clock class="w-4 h-4 text-neutral-500" /> icon="clock"
<p class="text-sm text-neutral-400">Avg Session</p> note="Duration"
</div> />
<p class="text-2xl font-bold text-neutral-100"> <StatCard
{{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m label="New players"
</p> :value="retentionData.summary.new_players"
<p class="text-xs text-neutral-600 mt-1">Duration</p> icon="trending-up"
</div> note="Last 30 days"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> />
<div class="flex items-center gap-2 mb-2"> <StatCard
<TrendingUp class="w-4 h-4 text-neutral-500" /> label="Returning"
<p class="text-sm text-neutral-400">New Players</p> :value="retentionData.summary.returning_players"
</div> icon="bar-chart-3"
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.new_players }}</p> note="Last 30 days"
<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> </div>
<!-- Retention curve chart --> <!-- Retention curve chart -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Retention curve (post-wipe return rates)">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4"> <div ref="retentionChart" class="retention-view__chart-area"></div>
Retention Curve (Post-Wipe Return Rates) <p class="retention-view__chart-note">
</h2> <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.
<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> </p>
</div> </Panel>
</div>
<!-- Wipe details table --> <!-- Wipe details table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Wipe details" :flush-body="true">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4"> <div class="retention-view__table-wrap">
Wipe Details <table class="retention-view__table">
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-neutral-800"> <tr class="retention-view__thead-row">
<th class="text-left py-2 px-3 text-neutral-400 font-medium">Wipe Date</th> <th class="retention-view__th retention-view__th--left">Wipe date</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">Pre-Wipe Players</th> <th class="retention-view__th retention-view__th--right">Pre-wipe players</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">24h Return</th> <th class="retention-view__th retention-view__th--right">24h return</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">48h Return</th> <th class="retention-view__th retention-view__th--right">48h return</th>
<th class="text-right py-2 px-3 text-neutral-400 font-medium">72h Return</th> <th class="retention-view__th retention-view__th--right">72h return</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="wipe in retentionData.wipe_metrics" v-for="wipe in retentionData.wipe_metrics"
:key="wipe.wipe_id" :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', { {{ new Date(wipe.wipe_date).toLocaleString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -306,38 +299,165 @@ onMounted(() => {
minute: '2-digit' minute: '2-digit'
}) }} }) }}
</td> </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 }} {{ wipe.total_players_before_wipe }}
</td> </td>
<td class="py-3 px-3 text-right"> <td class="retention-view__td retention-view__td--num">
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span> <span class="retention-view__count">{{ wipe.returned_24h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span> <span class="retention-view__pct">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
</td> </td>
<td class="py-3 px-3 text-right"> <td class="retention-view__td retention-view__td--num">
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span> <span class="retention-view__count">{{ wipe.returned_48h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span> <span class="retention-view__pct">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
</td> </td>
<td class="py-3 px-3 text-right"> <td class="retention-view__td retention-view__td--num">
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span> <span class="retention-view__count">{{ wipe.returned_72h }}</span>
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span> <span class="retention-view__pct">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </Panel>
</template> </template>
<!-- Empty state --> <!-- Empty state -->
<div <Panel v-else>
v-else <EmptyState
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center" icon="users"
> title="No retention data available"
<Users class="w-12 h-12 text-neutral-700 mx-auto mb-4" /> description="Player retention metrics will appear here after wipes are tracked and players join/leave."
<p class="text-neutral-500 mb-2">No retention data available</p> />
<p class="text-sm text-neutral-600"> </Panel>
Player retention metrics will appear here after wipes are tracked and players join/leave.
</p>
</div>
</div> </div>
</template> </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 { useServerStore } from '@/stores/server'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast' 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 server = useServerStore()
const api = useApi() const api = useApi()
@@ -50,6 +57,12 @@ const filteredPlayers = computed(() => {
const onlineCount = computed(() => players.value.filter(p => p.is_online).length) 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 { function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60) const m = Math.floor((seconds % 3600) / 60)
@@ -58,7 +71,7 @@ function formatPlaytime(seconds: number): string {
} }
function formatConnectedTime(iso: string | null): string { function formatConnectedTime(iso: string | null): string {
if (!iso) return '\u2014' if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime() const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000) const m = Math.floor(diff / 60000)
const h = Math.floor(m / 60) const h = Math.floor(m / 60)
@@ -66,6 +79,18 @@ function formatConnectedTime(iso: string | null): string {
return `${m}m` 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() { async function fetchPlayers() {
isLoading.value = true isLoading.value = true
try { try {
@@ -106,132 +131,197 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="pv">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="pv__head">
<div class="flex items-center gap-3"> <div class="pv__head-id">
<Users class="w-5 h-5 text-oxide-500" /> <div class="pv__head-chip">
<Icon name="users" :size="20" :stroke-width="2" />
</div>
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Player Management</h1> <div class="t-eyebrow">Player management</div>
<p class="text-sm text-neutral-500 mt-0.5"> <h1 class="pv__title">Players</h1>
{{ onlineCount }} online / {{ players.length }} total
</p>
</div> </div>
</div> </div>
<button <div class="pv__head-actions">
@click="fetchPlayers" <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" :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" @click="fetchPlayers"
> >Refresh</Button>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" /> </div>
Refresh
</button>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="flex items-center gap-4"> <div class="pv__filters">
<div class="relative flex-1 max-w-sm"> <Input
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="searchQuery" v-model="searchQuery"
type="text" icon="search"
placeholder="Search by name or Steam ID..." 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" size="sm"
:mono="false"
style="max-width: 320px;"
/> />
</div> <Tabs v-model="filterStatus" :items="statusTabItems" />
<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> </div>
<!-- Player table --> <!-- Player table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel :flush-body="true">
<table class="w-full"> <!-- 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> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th> <th>Player</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Steam ID</th> <th>Steam ID</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th> <th>Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Session</th> <th>Session</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Playtime</th> <th>Playtime</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Ping</th> <th>Ping</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th> <th class="pv__th-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<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>
<tr <tr
v-for="player in filteredPlayers" v-for="player in filteredPlayers"
:key="player.steam_id" :key="player.steam_id"
class="hover:bg-neutral-800/50 transition-colors"
> >
<td class="px-4 py-3"> <td>
<div class="flex items-center gap-2"> <div class="pv__player-name">
<span class="text-sm font-medium text-neutral-100">{{ player.display_name }}</span> <span class="pv__name">{{ player.display_name }}</span>
<Shield v-if="player.is_admin" class="w-3.5 h-3.5 text-oxide-400" title="Admin" /> <Badge v-if="player.is_admin" tone="accent" size="md">Admin</Badge>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ player.steam_id }}</td> <td class="pv__mono">{{ player.steam_id }}</td>
<td class="px-4 py-3"> <td>
<span <Badge
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full" :tone="playerStatusTone(player)"
:class="player.is_online :dot="true"
? 'bg-green-500/10 text-green-400' :pulse="player.is_online && !player.is_banned"
: player.is_banned >{{ playerStatusLabel(player) }}</Badge>
? '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> </td>
<td class="px-4 py-3 text-sm text-neutral-400"> <td class="pv__secondary">
{{ player.is_online ? formatConnectedTime(player.connected_at) : '\u2014' }} {{ player.is_online ? formatConnectedTime(player.connected_at) : '' }}
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatPlaytime(player.playtime_seconds) }}</td> <td class="pv__secondary pv__mono">{{ formatPlaytime(player.playtime_seconds) }}</td>
<td class="px-4 py-3 text-sm text-neutral-400"> <td class="pv__secondary pv__mono">
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '\u2014' }} {{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '' }}
</td> </td>
<td class="px-4 py-3 text-right"> <td class="pv__td-right">
<div class="flex items-center justify-end gap-1" v-if="player.is_online"> <div v-if="player.is_online" class="pv__row-actions">
<button <IconButton
icon="log-out"
variant="ghost"
size="sm"
label="Kick"
@click="kickPlayer(player.steam_id, player.display_name)" @click="kickPlayer(player.steam_id, player.display_name)"
class="p-1.5 text-neutral-500 hover:text-yellow-400 rounded transition-colors" />
title="Kick" <IconButton
> icon="ban"
<LogOut class="w-4 h-4" /> variant="danger"
</button> size="sm"
<button label="Ban"
@click="banPlayer(player.steam_id, player.display_name)" @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> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Panel>
</div> </div>
</template> </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 type { UmodPlugin } from '@/stores/plugins'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { PluginEntry } from '@/types' 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 pluginStore = usePluginStore()
const toast = useToastStore() 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 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 { function sourceLabel(source: string): string {
switch (source) { switch (source) {
case 'umod': return 'uMod' 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) { switch (source) {
case 'umod': return 'bg-green-500/10 text-green-400' case 'umod': return 'online'
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400' case 'corrosion_module': return 'accent'
default: return 'bg-neutral-700/50 text-neutral-400' default: return 'neutral'
} }
} }
@@ -168,274 +182,249 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="plv">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="plv__head">
<div class="flex items-center gap-3"> <div class="plv__head-id">
<Puzzle class="w-5 h-5 text-oxide-500" /> <div class="plv__head-chip">
<Icon name="puzzle" :size="20" :stroke-width="2" />
</div>
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1> <div class="t-eyebrow">Plugin management</div>
<p class="text-sm text-neutral-500 mt-0.5"> <h1 class="plv__title">Plugins</h1>
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
</p>
</div> </div>
</div> </div>
<button <div class="plv__head-actions">
@click="pluginStore.fetchPlugins()" <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" :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" @click="pluginStore.fetchPlugins()"
> >Refresh</Button>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" /> </div>
Refresh
</button>
</div> </div>
<!-- Tabs + Search --> <!-- Tab bar + search -->
<div class="flex items-center gap-4"> <div class="plv__toolbar">
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden"> <Tabs v-model="tab" :items="tabItems" />
<button <Input
@click="tab = 'installed'" v-if="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" v-model="searchQuery"
type="text" icon="search"
placeholder="Search installed plugins..." 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" size="sm"
style="max-width: 280px;"
/> />
</div> <Input
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm"> v-if="tab === 'browse'"
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model="browseQuery" v-model="browseQuery"
type="text" icon="search"
placeholder="Search uMod plugins..." placeholder="Search uMod plugins"
size="sm"
style="max-width: 280px;"
@input="scheduleBrowseSearch" @input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)" @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> </div>
</div>
<!-- Installed Plugins --> <!-- ===== INSTALLED TAB ===== -->
<div v-if="tab === 'installed'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel v-if="tab === 'installed'" :flush-body="true">
<table class="w-full"> <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> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th> <th>Plugin</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th> <th>Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th> <th>Source</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th> <th>Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th> <th>Wipe behavior</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th> <th class="plv__th-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<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>
<tr <tr
v-for="plugin in filteredPlugins" v-for="plugin in filteredPlugins"
:key="plugin.id" :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="plv__plugin-name">{{ plugin.plugin_name }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td> <td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
<td class="px-4 py-3"> <td>
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)"> <Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
{{ sourceLabel(plugin.source) }}
</span>
</td> </td>
<td class="px-4 py-3"> <td>
<span <Badge
class="inline-flex items-center gap-1.5 text-xs font-medium" :tone="plugin.is_loaded ? 'online' : 'offline'"
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'" :dot="true"
> :pulse="plugin.is_loaded"
<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' }}</Badge>
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
</span>
</td> </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-if="plugin.never_wipe">Never wipe</template>
<template v-else> <template v-else>
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }} {{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
</template> </template>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="plv__td-right">
<div class="flex items-center justify-end gap-1"> <div class="plv__row-actions">
<button <IconButton
:icon="plugin.is_loaded ? 'power' : 'play'"
variant="ghost"
size="sm"
:label="plugin.is_loaded ? 'Unload' : 'Load'"
@click="handleToggleLoad(plugin)" @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'" <IconButton
:title="plugin.is_loaded ? 'Unload' : 'Load'" icon="trash-2"
> variant="danger"
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" /> size="sm"
</button> label="Uninstall"
<button
@click="handleUninstall(plugin)" @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> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Panel>
<!-- Browse uMod --> <!-- ===== BROWSE UMOD TAB ===== -->
<div v-if="tab === 'browse'"> <div v-if="tab === 'browse'">
<!-- Empty state: no search yet --> <!-- 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"> <Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" /> <EmptyState
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3> icon="search"
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p> title="Search uMod"
</div> description="Type a plugin name above to search the uMod plugin directory."
/>
</Panel>
<!-- Loading --> <!-- Loading -->
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"> <Panel v-else-if="pluginStore.isBrowseLoading">
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" /> <div class="plv__loading">
<p class="text-sm text-neutral-500">Searching uMod...</p> <Icon name="loader" :size="20" class="plv__spin" />
<span>Searching uMod</span>
</div> </div>
</Panel>
<!-- No results --> <!-- 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"> <Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" /> <EmptyState
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3> icon="download"
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p> title="No plugins found"
</div> :description="`No uMod plugins matched &quot;${browseQuery}&quot;. Try a different search term.`"
/>
</Panel>
<!-- Results --> <!-- Results -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel v-else :flush-body="true">
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between"> <template #actions>
<p class="text-xs text-neutral-500"> <span v-if="pluginStore.browseResults" class="plv__browse-meta">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found {{ pluginStore.browseResults.total.toLocaleString() }} plugins
&bull; Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }} &middot; page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
</p> </span>
<div class="flex items-center gap-1"> <Button
<button variant="ghost"
@click="browsePrev" size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading" :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" @click="browsePrev"
> >Prev</Button>
&larr; Prev <Button
</button> variant="ghost"
<button size="sm"
@click="browseNext" icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading" :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" @click="browseNext"
> >Next</Button>
Next &rarr; </template>
</button> <table class="plv__table">
</div>
</div>
<table class="w-full">
<thead> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th> <th>Plugin</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th> <th>Author</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th> <th>Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th> <th>Downloads</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th> <th class="plv__th-right">Action</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr <tr
v-for="result in browsePlugins" v-for="result in browsePlugins"
:key="result.name" :key="result.name"
class="hover:bg-neutral-800/50 transition-colors"
> >
<td class="px-4 py-3"> <td>
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p> <div class="plv__browse-name">{{ result.title }}</div>
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p> <div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td> <td class="plv__secondary">{{ result.author ?? '' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td> <td class="plv__mono">{{ result.latest_release_version_formatted ?? '' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td> <td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '' }}</td>
<td class="px-4 py-3 text-right"> <td class="plv__td-right">
<button <Button
@click="installFromBrowse(result)" 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)" :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" @click="installFromBrowse(result)"
:class="isAlreadyInstalled(result.name) >{{ isAlreadyInstalled(result.name) ? 'Installed' : 'Install' }}</Button>
? '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>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Bottom pagination --> <!-- 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"> <div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
<p class="text-xs text-neutral-500"> <span class="plv__browse-meta">
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }} Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p> </span>
<div class="flex items-center gap-1"> <div class="plv__browse-pag">
<button <Button
@click="browsePrev" variant="secondary"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading" :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" @click="browsePrev"
> >Previous</Button>
&larr; Previous <Button
</button> variant="secondary"
<button size="sm"
@click="browseNext" icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading" :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" @click="browseNext"
> >Next</Button>
Next &rarr;
</button>
</div>
</div> </div>
</div> </div>
</Panel>
</div> </div>
<!-- Upload Custom Plugin --> <!-- ===== UPLOAD CUSTOM TAB ===== -->
<div v-if="tab === 'upload'" class="space-y-4"> <div v-if="tab === 'upload'" class="plv__upload-wrap">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6"> <Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
<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>
<!-- Drop zone --> <!-- Drop zone -->
<div <div
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer" class="plv__dropzone"
:class="isDragOver :class="{
? 'border-oxide-500 bg-oxide-500/5' 'plv__dropzone--active': isDragOver,
: uploadFile 'plv__dropzone--ready': !!uploadFile && !isDragOver,
? 'border-green-600 bg-green-900/10' }"
: 'border-neutral-700 hover:border-neutral-600'"
@dragover.prevent="isDragOver = true" @dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false" @dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
@@ -445,65 +434,174 @@ onMounted(() => {
ref="uploadInput" ref="uploadInput"
type="file" type="file"
accept=".cs" accept=".cs"
class="hidden" class="plv__file-hidden"
@change="handleFilePick" @change="handleFilePick"
/> />
<!-- No file selected --> <!-- No file -->
<template v-if="!uploadFile"> <template v-if="!uploadFile">
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" /> <div class="plv__drop-icon">
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p> <Icon name="upload" :size="22" :stroke-width="1.75" />
<p class="text-xs text-neutral-500 mt-1">or click to browse</p> </div>
<p class="plv__drop-label">Drop your .cs file here</p>
<p class="plv__drop-hint">or click to browse</p>
</template> </template>
<!-- File selected --> <!-- File selected -->
<template v-else> <template v-else>
<div class="flex items-center justify-center gap-3"> <div class="plv__file-row">
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" /> <div class="plv__file-icon">
<div class="text-left"> <Icon name="puzzle" :size="20" :stroke-width="1.75" />
<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> </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" @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> </div>
</template> </template>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-3 mt-4"> <div class="plv__upload-actions">
<button <Button
@click="handleUpload" icon="upload"
:loading="isUploading"
:disabled="!uploadFile || 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" @click="handleUpload"
> >{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" /> <Button
<Upload v-else class="w-4 h-4" />
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
</button>
<button
v-if="uploadFile" v-if="uploadFile"
variant="ghost"
@click="clearUpload" @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> </div>
</Panel>
<!-- Info card --> <Alert tone="info">
<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 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. for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</p> </Alert>
</div>
</div> </div>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { Clock, Plus, Edit, Trash2, Power, Loader2 } from 'lucide-vue-next'
import { safeDate } from '@/utils/formatters' 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 { interface ScheduledTask {
id: string id: string
@@ -12,7 +18,7 @@ interface ScheduledTask {
timezone: string timezone: string
is_active: boolean is_active: boolean
next_run: string | null next_run: string | null
task_config: Record<string, any> task_config: Record<string, unknown>
} }
const api = useApi() 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 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() { async function fetchTasks() {
isLoading.value = true isLoading.value = true
try { try {
@@ -95,167 +108,295 @@ async function toggleActive(task: ScheduledTask) {
await fetchTasks() await fetchTasks()
} }
function taskTypeLabel(type: string): string {
return type.replace('_', ' ')
}
onMounted(() => { onMounted(() => {
fetchTasks() fetchTasks()
}) })
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="schedules">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3"> <div>
<Clock class="w-5 h-5 text-oxide-500" /> <div class="t-eyebrow">Operations</div>
<h1 class="text-2xl font-bold text-neutral-100">Scheduled Tasks</h1> <h1 class="page__title">Scheduled tasks</h1>
</div> </div>
<button <Button icon="plus" @click="openCreateModal">New task</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>
</div> </div>
<!-- Tasks Table --> <!-- Tasks table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel :flush-body="true" title="Tasks">
<div v-if="isLoading" class="p-8 flex justify-center"> <div v-if="isLoading" class="loading-row">
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" /> <span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
</div> </div>
<div v-else-if="tasks.length === 0" class="p-8 text-center text-neutral-500">
No scheduled tasks configured. <EmptyState
</div> v-else-if="tasks.length === 0"
<table v-else class="w-full"> icon="calendar-clock"
<thead class="bg-neutral-800/50 border-b border-neutral-800"> 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> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Task Name</th> <th>Task name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th> <th>Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Schedule</th> <th>Schedule</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Timezone</th> <th>Timezone</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Next Run</th> <th>Next run</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Status</th> <th>Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr v-for="task in tasks" :key="task.id" class="hover:bg-neutral-800/30"> <tr v-for="task in tasks" :key="task.id">
<td class="px-4 py-3 text-sm text-neutral-200">{{ task.task_name }}</td> <td class="td-primary">{{ task.task_name }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ task.task_type.replace('_', ' ') }}</td> <td class="td-cap">{{ taskTypeLabel(task.task_type) }}</td>
<td class="px-4 py-3 text-sm font-mono text-neutral-400">{{ task.cron_expression }}</td> <td class="td-mono">{{ task.cron_expression }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.timezone }}</td> <td>{{ task.timezone }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(task.next_run, '—') }}</td> <td class="td-mono">{{ safeDate(task.next_run, '—') }}</td>
<td class="px-4 py-3"> <td>
<span <Badge :tone="task.is_active ? 'online' : 'neutral'">
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'"
>
{{ task.is_active ? 'Active' : 'Paused' }} {{ task.is_active ? 'Active' : 'Paused' }}
</span> </Badge>
</td> </td>
<td class="px-4 py-3"> <td>
<div class="flex items-center gap-2"> <div class="row-actions">
<button <IconButton
icon="power"
size="sm"
:label="task.is_active ? 'Pause' : 'Activate'"
@click="toggleActive(task)" @click="toggleActive(task)"
class="text-neutral-400 hover:text-oxide-400 transition-colors" />
:title="task.is_active ? 'Pause' : 'Activate'" <IconButton
> icon="pencil"
<Power class="w-4 h-4" /> size="sm"
</button> label="Edit"
<button
@click="openEditModal(task)" @click="openEditModal(task)"
class="text-neutral-400 hover:text-oxide-400 transition-colors" />
> <IconButton
<Edit class="w-4 h-4" /> icon="trash-2"
</button> variant="danger"
<button size="sm"
label="Delete"
@click="deleteTask(task.id)" @click="deleteTask(task.id)"
class="text-neutral-400 hover:text-red-400 transition-colors" />
>
<Trash2 class="w-4 h-4" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</Panel>
</div> </div>
<!-- Create/Edit Modal --> <!-- Create / Edit Modal -->
<Teleport to="body">
<div <div
v-if="showModal" 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" @click.self="showModal = false"
> >
<div class="bg-neutral-900 border border-neutral-800 rounded-lg w-full max-w-lg"> <div class="modal">
<div class="p-5 border-b border-neutral-800"> <div class="modal__head">
<h2 class="text-lg font-bold text-neutral-100">{{ editingTask ? 'Edit Task' : 'New Task' }}</h2> <h2 class="modal__title">{{ editingTask ? 'Edit task' : 'New task' }}</h2>
<IconButton icon="x" label="Close" @click="showModal = false" />
</div> </div>
<div class="p-5 space-y-4">
<div> <div class="modal__body">
<label class="block text-sm text-neutral-400 mb-2">Task Name</label> <Input
<input
v-model="formData.task_name" v-model="formData.task_name"
type="text" label="Task name"
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" placeholder="Daily restart"
/> />
</div>
<div> <Select
<label class="block text-sm text-neutral-400 mb-2">Task Type</label>
<select
v-model="formData.task_type" 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" label="Task type"
> :options="TASK_TYPE_OPTIONS"
<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> <Input
<div> v-model="formData.cron_expression"
<label class="block text-sm text-neutral-400 mb-2">Timezone</label> label="Cron expression"
<select placeholder="0 0 * * *"
:mono="true"
hint="Example: 0 0 * * * = daily at midnight"
/>
<Select
v-model="formData.timezone" 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" label="Timezone"
> :options="timezones"
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option> />
</select>
</div> <div class="cc-field">
<div> <span class="cc-field__label">Task config (JSON)</span>
<label class="block text-sm text-neutral-400 mb-2">Task Config (JSON)</label>
<textarea <textarea
v-model="formData.task_config" v-model="formData.task_config"
rows="4" 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..."}' placeholder='{"message": "Server restarting..."}'
class="cc-textarea cc-textarea--mono"
/> />
</div> </div>
</div> </div>
<div class="p-5 border-t border-neutral-800 flex justify-end gap-3">
<button <div class="modal__foot">
@click="showModal = false" <Button variant="secondary" @click="showModal = false">Cancel</Button>
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors" <Button @click="saveTask">{{ editingTask ? 'Update' : 'Create' }}</Button>
>
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>
</div> </div>
</div> </div>
</div> </div>
</Teleport>
</template> </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 { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { StoreConfig } from '@/types' 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 api = useApi()
const toast = useToastStore() const toast = useToastStore()
@@ -22,10 +28,18 @@ const isLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const isConfigured = 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 = [ const currencyOptions = [
{ value: 'USD', label: 'USD - US Dollar' }, { value: 'USD', label: 'USD US Dollar' },
{ value: 'EUR', label: 'EUR - Euro' }, { value: 'EUR', label: 'EUR Euro' },
{ value: 'GBP', label: 'GBP - British Pound' }, { value: 'GBP', label: 'GBP British Pound' },
] ]
const showPayPalWarning = computed(() => { const showPayPalWarning = computed(() => {
@@ -40,7 +54,6 @@ async function fetchConfig() {
isConfigured.value = !!data.config.store_name isConfigured.value = !!data.config.store_name
} catch (err: any) { } catch (err: any) {
if (err.response?.status === 404) { if (err.response?.status === 404) {
// No config yet, use defaults
isConfigured.value = false isConfigured.value = false
} else { } else {
toast.error('Failed to load store configuration') toast.error('Failed to load store configuration')
@@ -51,7 +64,6 @@ async function fetchConfig() {
} }
async function saveConfig() { async function saveConfig() {
// Validation
if (!config.value.store_name.trim()) { if (!config.value.store_name.trim()) {
toast.error('Store name is required') toast.error('Store name is required')
return return
@@ -73,7 +85,6 @@ async function saveConfig() {
enabled: config.value.enabled, enabled: config.value.enabled,
} }
// Only include secret if it was entered
if (paypalSecret.value.trim()) { if (paypalSecret.value.trim()) {
payload.paypal_client_secret = paypalSecret.value payload.paypal_client_secret = paypalSecret.value
} }
@@ -81,7 +92,7 @@ async function saveConfig() {
await api.put('/webstore/config', payload) await api.put('/webstore/config', payload)
toast.success('Store configuration saved successfully') toast.success('Store configuration saved successfully')
isConfigured.value = true isConfigured.value = true
paypalSecret.value = '' // Clear after save paypalSecret.value = ''
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.error || 'Failed to save configuration' const message = err.response?.data?.error || 'Failed to save configuration'
toast.error(message) toast.error(message)
@@ -96,202 +107,181 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="sc-page">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3">
<Store class="w-5 h-5 text-oxide-500" />
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Store Configuration</h1> <div class="t-eyebrow">Management</div>
<p class="text-sm text-neutral-500 mt-0.5"> <h1 class="page__title">Store configuration</h1>
Configure your integrated webstore and PayPal settings <p class="page__sub">Configure your integrated webstore and PayPal settings.</p>
</p>
</div> </div>
</div> <Button :loading="saving" :disabled="isLoading" icon="save" @click="saveConfig">
<button Save configuration
@click="saveConfig" </Button>
: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"
>
<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> </div>
<!-- Loading state --> <!-- Loading skeleton -->
<div v-if="isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"> <div v-if="isLoading" class="sc-loading">
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" /> <EmptyState icon="loader" title="Loading configuration" description="Fetching your store settings…" />
<p class="text-sm text-neutral-500">Loading configuration...</p>
</div> </div>
<!-- Empty state --> <!-- Empty state no config yet -->
<div <Panel v-else-if="!isConfigured && !config.store_name">
v-else-if="!isConfigured && !config.store_name" <EmptyState
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center" 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."
> >
<Store class="w-12 h-12 text-neutral-600 mx-auto mb-4" /> <template #action>
<h3 class="text-lg font-medium text-neutral-200 mb-2">No Store Configured</h3> <Button size="sm" variant="outline" icon="arrow-down">Complete the form below</Button>
<p class="text-sm text-neutral-500 mb-6 max-w-md mx-auto"> </template>
Set up your integrated webstore to start selling in-game items, ranks, and currency to your players. </EmptyState>
</p> </Panel>
<p class="text-xs text-neutral-600">
Fill out the form below to get started.
</p>
</div>
<!-- Configuration form --> <!-- Configuration form -->
<div v-else class="space-y-6"> <div v-else class="sc-form">
<!-- Store enable toggle with warning --> <!-- Enable toggle -->
<div <Panel title="Webstore status" subtitle="Allow players to purchase items from your store">
class="bg-neutral-900 border rounded-lg p-5" <div class="sc-toggle-row">
:class="config.enabled ? 'border-oxide-500/50' : 'border-neutral-800'" <div class="sc-toggle-row__info">
> <span class="sc-toggle-row__label">Enable webstore</span>
<div class="flex items-center justify-between"> <span class="sc-toggle-row__hint">Players will be able to browse and purchase items when enabled.</span>
<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> </div>
<button <Switch v-model="config.enabled" />
@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>
</div> </div>
<!-- Warning when enabled without PayPal --> <Alert
<div
v-if="showPayPalWarning" 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" /> Configure PayPal credentials in the section below before the store can process transactions.
<div class="text-xs text-yellow-300"> </Alert>
<strong>PayPal configuration required.</strong> You must configure PayPal credentials below before the store can process transactions. </Panel>
</div>
</div>
</div>
<!-- Basic store info --> <!-- Store information -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4"> <Panel title="Store information" eyebrow="General">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Store Information</h2> <div class="sc-fields">
<Input
<div>
<label class="block text-xs text-neutral-500 mb-1">Store Name *</label>
<input
v-model="config.store_name" v-model="config.store_name"
type="text" label="Store name"
placeholder="My Rust Server Store" placeholder="My Rust Server Store"
maxlength="200" :required="true"
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" 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="cc-field">
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label> <span class="cc-field__label">Description <span class="sc-opt">(optional)</span></span>
<textarea <textarea
v-model="config.description" v-model="config.description"
placeholder="Welcome to our server store! Support us and get awesome in-game items..." class="cc-textarea"
rows="3" 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" placeholder="Welcome to our server store! Support us and get awesome in-game items…"
></textarea> />
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the store page</p> <span class="cc-field__hint">Brief description shown on the store page.</span>
</div> </label>
<div> <Select
<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" 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" label="Currency"
> :options="currencyOptions"
<option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value"> hint="Currency used for all transactions"
{{ opt.label }} />
</option>
</select>
</div>
<p class="text-xs text-neutral-600 mt-1">Currency used for all transactions</p>
</div>
</div> </div>
</Panel>
<!-- PayPal configuration --> <!-- PayPal configuration -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4"> <Panel title="PayPal configuration" eyebrow="Payments" subtitle="Get your API credentials from the PayPal Developer Dashboard.">
<div> <div class="sc-fields">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">PayPal Configuration</h2> <Input
<p class="text-xs text-neutral-500 mt-1"> v-model="paypalClientId"
Get your API credentials from the label="PayPal Client ID"
<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"
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 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> <Input
<label class="block text-xs text-neutral-500 mb-1">PayPal Client Secret *</label>
<input
v-model="paypalSecret" v-model="paypalSecret"
label="PayPal Client Secret"
type="password" type="password"
placeholder="Enter to update (stored encrypted)" 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 --> <!-- Sandbox mode toggle -->
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700"> <div class="sc-sandbox">
<div> <div class="sc-toggle-row">
<p class="text-sm font-medium text-neutral-200">Sandbox Mode</p> <div class="sc-toggle-row__info">
<p class="text-xs text-neutral-500 mt-1"> <span class="sc-toggle-row__label">Sandbox mode</span>
Use PayPal sandbox for testing (no real transactions) <span class="sc-toggle-row__hint">Use PayPal sandbox for testing no real transactions.</span>
</p> </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"
>
<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>
</div> </div>
<!-- Production warning --> <Alert
<div
v-if="!config.sandbox_mode && config.enabled" 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" tone="danger"
title="Production mode active"
> >
<AlertTriangle class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" /> Real transactions will be processed. Ensure your PayPal credentials are correct.
<div class="text-xs text-red-300"> </Alert>
<strong>Production mode enabled.</strong> Real transactions will be processed. Ensure your PayPal credentials are correct.
</div>
</div>
</div> </div>
</Panel>
</div> </div>
</div> </div>
</template> </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 { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import type { StoreCategory, StoreItem } from '@/types' import type { StoreCategory, StoreItem } from '@/types'
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
import { safeFixed } from '@/utils/formatters' 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() const api = useApi()
@@ -42,17 +50,23 @@ const itemTypes = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' }, { value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' }, { value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' }, { 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 { const tabItems = computed(() => [
switch (type) { { value: 'categories', label: 'Categories', count: categories.value.length },
case 'kit': return 'bg-blue-500/15 text-blue-400' { value: 'items', label: 'Items', count: items.value.length },
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' type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
default: return 'bg-neutral-700/50 text-neutral-400' 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 { function autoGenerateSlug(name: string): string {
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
} }
async function saveItem() { async function saveItem() {
// Validate
if (!itemForm.value.name.trim()) { if (!itemForm.value.name.trim()) {
alert('Item name is required') alert('Item name is required')
return return
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
function getCategoryName(categoryId: string | null): string { function getCategoryName(categoryId: string | null): string {
if (!categoryId) return 'Uncategorized' if (!categoryId) return 'Uncategorized'
const cat = categories.value.find(c => c.id === categoryId) const cat = categories.value.find(c => c.id === categoryId)
return cat?.name || 'Unknown' return cat?.name ?? 'Unknown'
} }
const selectedTypeExample = computed(() => { const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type) 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(() => { onMounted(() => {
@@ -249,447 +272,408 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="si-page">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3">
<ShoppingBag class="w-5 h-5 text-oxide-500" />
<div> <div>
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1> <div class="t-eyebrow">Management · Store</div>
<p class="text-sm text-neutral-500 mt-0.5"> <h1 class="page__title">Store items</h1>
{{ categories.length }} categories, {{ items.length }} items <p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
</p>
</div> </div>
</div> <div class="page__actions">
<div class="flex items-center gap-3"> <IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
<button <Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
@click="tab === 'categories' ? fetchCategories() : fetchItems()" <Button v-else icon="plus" @click="openItemModal()">Add item</Button>
: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> </div>
</div> </div>
<!-- Tabs --> <!-- Tab bar + content panel -->
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit"> <Panel :flush-body="true">
<button <template #actions>
@click="tab = 'categories'" <Tabs v-model="tab" :items="tabItems" />
class="px-4 py-2 text-sm font-medium transition-colors" </template>
: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>
<!-- Categories Tab --> <!-- Categories table -->
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <table v-if="tab === 'categories'" class="si-table">
<table class="w-full">
<thead> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Name</th> <th>Name</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th> <th>Slug</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th> <th class="si-col-num">Order</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th> <th>Visibility</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th> <th class="si-col-actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr v-if="categories.length === 0"> <tr v-if="categories.length === 0">
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm"> <td colspan="5" class="si-empty-cell">
<template v-if="isLoading">Loading categories...</template> <EmptyState
<template v-else>No categories yet. Add one to organize your store items.</template> icon="folder-open"
:title="isLoading ? 'Loading…' : 'No categories'"
:description="isLoading ? '' : 'Add a category to organize your store items.'"
/>
</td> </td>
</tr> </tr>
<tr <tr v-for="category in categories" :key="category.id">
v-for="category in categories" <td class="si-cell-primary">
:key="category.id" <span class="si-name">{{ category.name }}</span>
class="hover:bg-neutral-800/50 transition-colors" <span v-if="category.description" class="si-sub">{{ category.description }}</span>
>
<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>
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td> <td><span class="si-mono">{{ category.slug }}</span></td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td> <td class="si-col-num si-mono">{{ category.display_order }}</td>
<td class="px-4 py-3"> <td>
<span <Badge :tone="category.visible ? 'online' : 'neutral'">
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'"
>
{{ category.visible ? 'Visible' : 'Hidden' }} {{ category.visible ? 'Visible' : 'Hidden' }}
</span> </Badge>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="si-col-actions">
<div class="flex items-center justify-end gap-1"> <div class="si-row-actions">
<button <IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
@click="openCategoryModal(category)" <IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(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>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<!-- Items Tab --> <!-- Items table -->
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <table v-if="tab === 'items'" class="si-table">
<table class="w-full">
<thead> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th> <th>Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th> <th>Category</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th> <th>Type</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th> <th class="si-col-price">Price</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th> <th>Commands</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th> <th>Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th> <th class="si-col-actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr v-if="items.length === 0"> <tr v-if="items.length === 0">
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm"> <td colspan="7" class="si-empty-cell">
<template v-if="isLoading">Loading items...</template> <EmptyState
<template v-else>No items yet. Add items to start selling.</template> icon="shopping-bag"
:title="isLoading ? 'Loading…' : 'No items'"
:description="isLoading ? '' : 'Add items to start selling.'"
/>
</td> </td>
</tr> </tr>
<tr <tr v-for="item in items" :key="item.id">
v-for="item in items" <td class="si-cell-primary">
:key="item.id" <span class="si-name">{{ item.name }}</span>
class="hover:bg-neutral-800/50 transition-colors" <span v-if="item.description" class="si-sub">{{ item.description }}</span>
>
<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>
</td> </td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td> <td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
<td class="px-4 py-3"> <td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)"> <td class="si-col-price">
{{ item.item_type }} <span class="si-price">${{ safeFixed(item.price, 2) }}</span>
</span>
</td> </td>
<td class="px-4 py-3"> <td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
<div class="flex items-center gap-1 text-sm text-neutral-200"> <td>
<DollarSign class="w-3.5 h-3.5 text-neutral-500" /> <Badge :tone="item.enabled ? 'online' : 'neutral'">
{{ 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'"
>
{{ item.enabled ? 'Enabled' : 'Disabled' }} {{ item.enabled ? 'Enabled' : 'Disabled' }}
</span> </Badge>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="si-col-actions">
<div class="flex items-center justify-end gap-1"> <div class="si-row-actions">
<button <IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
@click="openItemModal(item)" <IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(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>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </Panel>
<!-- Category Modal --> <!-- ===== Category modal ===== -->
<div <div
v-if="showCategoryModal" 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" @click.self="closeCategoryModal"
> >
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full"> <div class="si-modal">
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between"> <div class="si-modal__head">
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2> <h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"> <IconButton icon="x" label="Close" @click="closeCategoryModal" />
<X class="w-5 h-5" />
</button>
</div> </div>
<div class="p-6 space-y-4"> <div class="si-modal__body">
<div> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
<input
v-model="categoryForm.name" v-model="categoryForm.name"
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)" label="Name"
type="text"
placeholder="VIP Kits" 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" @input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
/> />
</div> <Input
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
<input
v-model="categoryForm.slug" v-model="categoryForm.slug"
type="text" label="Slug (URL-safe)"
placeholder="vip-kits" 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" :mono="true"
/> />
</div> <label class="cc-field">
<div> <span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<textarea <textarea
v-model="categoryForm.description" v-model="categoryForm.description"
class="cc-textarea"
rows="2" rows="2"
placeholder="Premium kits for VIP players" 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> </label>
<div> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label> v-model="categoryForm.display_order as any"
<input label="Display order"
v-model.number="categoryForm.display_order"
type="number" 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"
/> />
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
</div> </div>
<div class="flex items-center gap-3"> <div class="si-modal__foot">
<input <Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
v-model="categoryForm.visible" <Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
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>
</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> </div>
</div> </div>
</div> </div>
<!-- Item Modal --> <!-- ===== Item modal ===== -->
<div <div
v-if="showItemModal" 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" @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="si-modal si-modal--wide">
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between"> <div class="si-modal__head si-modal__head--sticky">
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2> <h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"> <IconButton icon="x" label="Close" @click="closeItemModal" />
<X class="w-5 h-5" />
</button>
</div> </div>
<div class="p-6 space-y-6"> <div class="si-modal__body">
<!-- Basic Info --> <!-- Basic info section -->
<div class="space-y-4"> <div class="si-section">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3> <div class="si-section__label">Basic information</div>
<div> <Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label> <label class="cc-field">
<input <span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
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>
<textarea <textarea
v-model="itemForm.description" v-model="itemForm.description"
class="cc-textarea"
rows="2" rows="2"
placeholder="Get started with essential gear and resources" 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" />
</label>
<Select
v-model="categorySelectValue"
label="Category"
:options="categorySelectOptions"
/> />
</div> </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>
</div>
<!-- Pricing --> <!-- Pricing section -->
<div class="space-y-4"> <div class="si-section">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3> <div class="si-section__label">Pricing</div>
<div> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label> v-model="itemForm.price as any"
<div class="relative"> label="Price (USD)"
<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" type="number"
step="0.01" prefix="$"
min="0.01"
placeholder="9.99" 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>
</div>
</div>
<!-- Item Type --> <!-- Item type section -->
<div class="space-y-4"> <div class="si-section">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3> <div class="si-section__label">Item type</div>
<div class="grid grid-cols-2 gap-2"> <div class="si-type-grid">
<button <button
v-for="type in itemTypes" v-for="type in itemTypes"
:key="type.value" :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" @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 }} {{ type.label }}
</button> </button>
</div> </div>
</div> </div>
<!-- Delivery Commands --> <!-- Delivery commands section -->
<div class="space-y-4"> <div class="si-section">
<div class="flex items-center justify-between"> <div class="si-section__label-row">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3> <div class="si-section__label">Delivery commands</div>
<button <Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</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>
</div> </div>
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs"> <div class="si-cmd-hint">
<p class="text-neutral-400">Use placeholders:</p> <p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
<p class="text-neutral-300 font-mono">{'{steam_id}'} - 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="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p> <p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
</div> </div>
<div class="space-y-2"> <div class="si-commands">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2"> <div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
<input <Input
v-model="itemForm.delivery_commands[index]" v-model="itemForm.delivery_commands[index]"
type="text" :mono="true"
placeholder="inventory.giveto {steam_id} rifle.ak 1" 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" v-if="itemForm.delivery_commands.length > 1"
icon="trash-2"
label="Remove"
variant="danger"
size="sm"
@click="removeCommand(index)" @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> </div>
</div> </div>
<!-- Additional Settings --> <!-- Additional settings section -->
<div class="space-y-4"> <div class="si-section">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3> <div class="si-section__label">Additional settings</div>
<div> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
<input
v-model="itemForm.image_url" v-model="itemForm.image_url"
type="text" label="Image URL"
placeholder="https://example.com/image.png" 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" hint="Optional preview image for the store page."
/> />
</div> <Input
<div> v-model="itemForm.limit_per_player as any"
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label> label="Purchase limit per player"
<input
v-model.number="itemForm.limit_per_player"
type="number" type="number"
min="0"
placeholder="Leave empty for unlimited" 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" hint="Optional. Restricts how many times a player can purchase this item."
/> />
</div> <Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
<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> </div>
</div> </div>
</div> <div class="si-modal__foot">
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3"> <Button variant="secondary" @click="closeItemModal">Cancel</Button>
<button <Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</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> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { DollarSign, TrendingUp, Clock, AlertCircle, Download, RefreshCw } from 'lucide-vue-next'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import type { StoreTransaction } from '@/types' import type { StoreTransaction } from '@/types'
import { safeCurrency, safeDate } from '@/utils/formatters' 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() const api = useApi()
@@ -45,15 +50,16 @@ const formatCurrency = (amount: number, currency: string = 'USD'): string => {
return safeCurrency(amount, symbol) return safeCurrency(amount, symbol)
} }
// Status badge color classes // Status badge tone map
const statusBadgeClass = (status: string): string => { type BadgeTone = 'online' | 'warn' | 'info' | 'offline' | 'neutral'
const statusTone = (status: string): BadgeTone => {
switch (status) { switch (status) {
case 'delivered': return 'bg-green-500/10 text-green-400' case 'delivered': return 'online'
case 'paid': return 'bg-yellow-500/10 text-yellow-400' case 'paid': return 'warn'
case 'pending': return 'bg-blue-500/10 text-blue-400' case 'pending': return 'info'
case 'failed': return 'bg-red-500/10 text-red-400' case 'failed': return 'offline'
case 'refunded': return 'bg-neutral-500/10 text-neutral-400' case 'refunded': return 'neutral'
default: return 'bg-neutral-700/50 text-neutral-400' 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) // Render revenue chart (last 30 days, grouped by day)
const renderRevenueChart = () => { const renderRevenueChart = () => {
if (!revenueChart.value || transactions.value.length === 0) return if (!revenueChart.value || transactions.value.length === 0) return
@@ -106,16 +116,24 @@ const renderRevenueChart = () => {
d.setDate(d.getDate() - i) d.setDate(d.getDate() - i)
const dateKey = d.toLocaleDateString('en-US') const dateKey = d.toLocaleDateString('en-US')
dates.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })) 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({ revenueChartInstance.setOption({
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' }, textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => { formatter: (params: any) => {
const value = params[0]?.data const value = params[0]?.data
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}` return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
@@ -131,15 +149,15 @@ const renderRevenueChart = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: dates, data: dates,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'Revenue ($)', name: 'Revenue ($)',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080', formatter: (value: number) => `$${value}` } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10, formatter: (value: number) => `$${value}` }
}, },
series: [ series: [
{ {
@@ -186,156 +204,136 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="revenue-view">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="revenue-view__header">
<div class="flex items-center gap-3"> <h1 class="revenue-view__title">Revenue dashboard</h1>
<DollarSign class="w-5 h-5 text-oxide-500" /> <div class="revenue-view__controls">
<h1 class="text-2xl font-bold text-neutral-100">Revenue Dashboard</h1> <Button
</div> variant="secondary"
<div class="flex items-center gap-3"> size="sm"
<button icon="refresh-cw"
:loading="loading"
@click="loadTransactions" @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 Refresh
</button> </Button>
<button <Button
@click="exportCSV" variant="secondary"
size="sm"
icon="download"
:disabled="transactions.length === 0" :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 Export CSV
</button> </Button>
</div> </div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="revenue-view__loading">
<div class="text-neutral-500">Loading transaction data...</div> <span class="revenue-view__loading-text">Loading transaction data...</span>
</div> </div>
<template v-else> <template v-else>
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="revenue-view__stats">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Total revenue"
<DollarSign class="w-4 h-4 text-neutral-500" /> :value="formatCurrency(totalRevenue)"
<p class="text-sm text-neutral-400">Total Revenue</p> icon="dollar-sign"
</div> note="Last 100 transactions"
<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> <StatCard
</div> label="Total transactions"
:value="totalTransactions"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> icon="trending-up"
<div class="flex items-center gap-2 mb-2"> note="All time"
<TrendingUp class="w-4 h-4 text-neutral-500" /> />
<p class="text-sm text-neutral-400">Total Transactions</p> <StatCard
</div> label="Pending deliveries"
<p class="text-2xl font-bold text-neutral-100">{{ totalTransactions }}</p> :value="pendingDeliveries"
<p class="text-xs text-neutral-600 mt-1">All time</p> icon="clock"
</div> note="Paid, not delivered"
/>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Refunds"
<Clock class="w-4 h-4 text-neutral-500" /> :value="refunds"
<p class="text-sm text-neutral-400">Pending Deliveries</p> icon="alert-circle"
</div> note="Total refunded"
<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> </div>
<!-- Revenue Chart --> <!-- Revenue Chart -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Revenue over time (last 30 days)">
<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="revenue-view__chart-area"></div>
<div ref="revenueChart" class="h-64"></div> </Panel>
</div>
<!-- Transaction Table --> <!-- Transaction Table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"> <Panel title="Transaction history" :flush-body="true">
<div class="px-4 py-3 border-b border-neutral-800 flex items-center justify-between"> <template #actions>
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Transaction History</h2> <Select
<div class="flex items-center gap-2"> :options="[
<label class="text-xs text-neutral-500">Filter:</label> { value: 'all', label: 'All statuses' },
<select { value: 'delivered', label: 'Delivered' },
{ value: 'paid', label: 'Paid' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
{ value: 'refunded', label: 'Refunded' }
]"
v-model="statusFilter" 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" size="sm"
> />
<option value="all">All</option> </template>
<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>
<table class="w-full"> <div class="revenue-view__table-wrap">
<table class="revenue-view__table">
<thead> <thead>
<tr class="border-b border-neutral-800 text-left"> <tr class="revenue-view__thead-row">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Date</th> <th class="revenue-view__th revenue-view__th--left">Date</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th> <th class="revenue-view__th revenue-view__th--left">Player</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th> <th class="revenue-view__th revenue-view__th--left">Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount</th> <th class="revenue-view__th revenue-view__th--right">Amount</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th> <th class="revenue-view__th revenue-view__th--left">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Delivered</th> <th class="revenue-view__th revenue-view__th--left">Delivered</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-800"> <tbody>
<tr v-if="filteredTransactions.length === 0"> <tr v-if="filteredTransactions.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm"> <td colspan="6" class="revenue-view__td-empty">
<template v-if="statusFilter !== 'all'">No {{ statusFilter }} transactions found.</template> <EmptyState
<template v-else>No transactions yet. Sales will appear here.</template> icon="dollar-sign"
:title="statusFilter !== 'all' ? `No ${statusFilter} transactions` : 'No transactions yet'"
:description="statusFilter !== 'all' ? '' : 'Sales will appear here once customers make purchases.'"
/>
</td> </td>
</tr> </tr>
<tr <tr
v-for="txn in filteredTransactions" v-for="txn in filteredTransactions"
:key="txn.id" :key="txn.id"
class="hover:bg-neutral-800/50 transition-colors" class="revenue-view__row"
> >
<td class="px-4 py-3"> <td class="revenue-view__td">
<p class="text-sm text-neutral-300">{{ formatDate(txn.created_at) }}</p> <p class="revenue-view__cell-text">{{ formatDate(txn.created_at) }}</p>
</td> </td>
<td class="px-4 py-3"> <td class="revenue-view__td">
<p class="text-sm font-medium text-neutral-100">{{ txn.player_name || 'Unknown' }}</p> <p class="revenue-view__cell-primary">{{ txn.player_name || 'Unknown' }}</p>
<p class="text-xs text-neutral-500 font-mono">{{ txn.steam_id }}</p> <p class="revenue-view__cell-mono">{{ txn.steam_id }}</p>
</td> </td>
<td class="px-4 py-3"> <td class="revenue-view__td">
<p class="text-sm text-neutral-300">{{ txn.item_id || '—' }}</p> <p class="revenue-view__cell-text">{{ txn.item_id || '—' }}</p>
</td> </td>
<td class="px-4 py-3"> <td class="revenue-view__td revenue-view__td--right">
<p class="text-sm font-medium text-neutral-200">{{ formatCurrency(txn.amount, txn.currency) }}</p> <p class="revenue-view__cell-primary revenue-view__cell-mono">{{ formatCurrency(txn.amount, txn.currency) }}</p>
</td> </td>
<td class="px-4 py-3"> <td class="revenue-view__td">
<span <Badge :tone="statusTone(txn.status)" uppercase>{{ txn.status }}</Badge>
class="text-xs font-medium px-2 py-0.5 rounded-full uppercase"
:class="statusBadgeClass(txn.status)"
>
{{ txn.status }}
</span>
</td> </td>
<td class="px-4 py-3"> <td class="revenue-view__td">
<span <Badge :tone="txn.delivered ? 'online' : 'neutral'">
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' }} {{ txn.delivered ? 'Yes' : 'No' }}
</span> </Badge>
<p v-if="txn.delivered_at" class="text-xs text-neutral-600 mt-1"> <p v-if="txn.delivered_at" class="revenue-view__cell-sub">
{{ formatDate(txn.delivered_at) }} {{ formatDate(txn.delivered_at) }}
</p> </p>
</td> </td>
@@ -343,6 +341,140 @@ onMounted(() => {
</tbody> </tbody>
</table> </table>
</div> </div>
</Panel>
</template> </template>
</div> </div>
</template> </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"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { BarChart3, TrendingUp, Clock, Target, Download, Zap } from 'lucide-vue-next'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { ECharts } from 'echarts' import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import type { WipePerformanceMetrics } from '@/types' import type { WipePerformanceMetrics } from '@/types'
import { safeFixed } from '@/utils/formatters' 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() const api = useApi()
@@ -42,9 +47,22 @@ const loadAnalytics = async () => {
} }
} }
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => { const renderCharts = () => {
if (!metrics.value) return 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 // Success Rate Timeline
if (successRateChart.value) { if (successRateChart.value) {
if (successRateChartInstance) { if (successRateChartInstance) {
@@ -62,9 +80,9 @@ const renderCharts = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' }, textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => { formatter: (params: any) => {
const status = params[0].data === 1 ? 'Success' : 'Failed' const status = params[0].data === 1 ? 'Success' : 'Failed'
const color = params[0].data === 1 ? '#10b981' : '#ef4444' const color = params[0].data === 1 ? '#10b981' : '#ef4444'
@@ -81,17 +99,19 @@ const renderCharts = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: dates, data: dates,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
min: 0, min: 0,
max: 1, max: 1,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { axisLabel: {
color: '#808080', color: labelColor,
fontFamily: mono,
fontSize: 10,
formatter: (value: number) => value === 1 ? 'Success' : 'Failed' formatter: (value: number) => value === 1 ? 'Success' : 'Failed'
} }
}, },
@@ -122,9 +142,9 @@ const renderCharts = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' } textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
}, },
grid: { grid: {
left: '3%', left: '3%',
@@ -136,15 +156,15 @@ const renderCharts = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: ['Day 1', 'Day 2', 'Day 3'], data: ['Day 1', 'Day 2', 'Day 3'],
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'Avg Players', name: 'Avg Players',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
series: [ series: [
{ {
@@ -157,8 +177,8 @@ const renderCharts = () => {
], ],
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#CE422B' }, { offset: 0, color: accent },
{ offset: 1, color: '#8B2E1F' } { offset: 1, color: accent + '99' }
]) ])
}, },
barWidth: '50%' barWidth: '50%'
@@ -184,9 +204,9 @@ const renderCharts = () => {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1a1a1a', backgroundColor: tooltipBg,
borderColor: '#2a2a2a', borderColor: tooltipBorder,
textStyle: { color: '#e5e5e5' }, textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => { formatter: (params: any) => {
return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes` return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes`
} }
@@ -201,15 +221,15 @@ const renderCharts = () => {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: dates, data: dates,
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: '#808080', rotate: 45 } axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'Minutes', name: 'Minutes',
axisLine: { lineStyle: { color: '#404040' } }, axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: '#2a2a2a' } }, splitLine: { lineStyle: { color: grid } },
axisLabel: { color: '#808080' } axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
}, },
series: [ series: [
{ {
@@ -264,128 +284,190 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="wipe-analytics-view">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="wipe-analytics-view__header">
<div class="flex items-center gap-3"> <h1 class="wipe-analytics-view__title">Wipe analytics</h1>
<Zap class="w-5 h-5 text-oxide-500" /> <div class="wipe-analytics-view__controls">
<h1 class="text-2xl font-bold text-neutral-100">Wipe Analytics</h1> <Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
</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" />
Export CSV Export CSV
</button> </Button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden"> <Tabs
<button :items="[
v-for="opt in (['6', '12', 'all'] as const)" { value: '6', label: 'Last 6 wipes' },
:key="opt" { value: '12', label: 'Last 12 wipes' },
@click="timeRange = opt" { value: 'all', label: 'All time' }
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'" v-model="timeRange"
> variant="pill"
{{ opt === 'all' ? 'All Time' : `Last ${opt} Wipes` }} />
</button>
</div>
</div> </div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="wipe-analytics-view__loading">
<div class="text-neutral-500">Loading wipe analytics...</div> <span class="wipe-analytics-view__loading-text">Loading wipe analytics...</span>
</div> </div>
<template v-else-if="metrics"> <template v-else-if="metrics">
<!-- Insight Cards --> <!-- Insight Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="wipe-analytics-view__stats">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <StatCard
<div class="flex items-center gap-2 mb-2"> label="Success rate"
<Target class="w-4 h-4 text-neutral-500" /> :value="safeFixed(metrics?.success_rate_percent, 1)"
<p class="text-sm text-neutral-400">Success Rate</p> unit="%"
</div> icon="target"
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(metrics?.success_rate_percent, 1) }}%</p> :note="`${metrics.successful_wipes}/${metrics.total_wipes} wipes`"
<p class="text-xs text-neutral-600 mt-1">{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes</p> />
</div> <StatCard
label="Avg duration"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> :value="formatDuration(metrics.avg_duration_seconds)"
<div class="flex items-center gap-2 mb-2"> icon="clock"
<Clock class="w-4 h-4 text-neutral-500" /> note="Per wipe"
<p class="text-sm text-neutral-400">Avg Duration</p> />
</div> <StatCard
<p class="text-2xl font-bold text-neutral-100">{{ formatDuration(metrics.avg_duration_seconds) }}</p> label="Peak population"
<p class="text-xs text-neutral-600 mt-1">Per wipe</p> value="Day 1"
</div> icon="trending-up"
:note="`${safeFixed(metrics?.population_curve?.day_1_avg, 1)} avg players`"
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> />
<div class="flex items-center gap-2 mb-2"> <StatCard
<TrendingUp class="w-4 h-4 text-neutral-500" /> label="Optimal timing"
<p class="text-sm text-neutral-400">Peak Population</p> :value="metrics.optimal_wipe_day"
</div> icon="bar-chart-3"
<p class="text-2xl font-bold text-neutral-100">Day 1</p> :note="`${metrics.optimal_wipe_hour}:00`"
<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> </div>
<!-- Actionable Insight Banner --> <!-- Actionable Insight Banner -->
<div v-if="metrics.total_wipes > 3" class="bg-oxide-500/10 border border-oxide-500/30 rounded-lg p-4"> <Alert
<div class="flex items-start gap-3"> v-if="metrics.total_wipes > 3"
<Target class="w-5 h-5 text-oxide-400 mt-0.5" /> tone="accent"
<div> title="Recommendations"
<p class="text-sm font-medium text-neutral-100 mb-1">Recommendations</p> >
<ul class="text-sm text-neutral-300 space-y-1"> <ul class="wipe-analytics-view__recs">
<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>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"> <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. Players peak on day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <strong>weekly wipes</strong> to maintain engagement.
</li> </li>
<li v-if="metrics.avg_duration_seconds > 600"> <li v-if="metrics.avg_duration_seconds > 600">
Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities. Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
</li> </li>
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0"> <li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
{{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles. {{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
</li> </li>
</ul> </ul>
</div> </Alert>
</div>
</div>
<!-- Charts --> <!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="wipe-analytics-view__charts">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Wipe success timeline">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Wipe Success Timeline</h2> <div ref="successRateChart" class="wipe-analytics-view__chart-area"></div>
<div ref="successRateChart" class="h-64"></div> </Panel>
<Panel title="Population curve post-wipe">
<div ref="populationCurveChart" class="wipe-analytics-view__chart-area"></div>
</Panel>
</div> </div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Wipe duration trend">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Population Curve Post-Wipe</h2> <div ref="durationTrendChart" class="wipe-analytics-view__chart-area"></div>
<div ref="populationCurveChart" class="h-64"></div> </Panel>
</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">Wipe Duration Trend</h2>
<div ref="durationTrendChart" class="h-64"></div>
</div>
<!-- No Data State --> <!-- No Data State -->
<div v-if="metrics.total_wipes === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8"> <Panel v-if="metrics.total_wipes === 0">
<div class="text-center"> <EmptyState
<Zap class="w-12 h-12 text-neutral-700 mx-auto mb-3" /> icon="zap"
<p class="text-neutral-400 mb-1">No wipe data yet</p> title="No wipe data yet"
<p class="text-sm text-neutral-600">Wipe analytics will appear after your first scheduled or manual wipe.</p> description="Wipe analytics will appear after your first scheduled or manual wipe."
</div> />
</div> </Panel>
</template> </template>
</div> </div>
</template> </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 { useWipeStore } from '@/stores/wipe'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import type { WipeProfile } from '@/types' 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 wipeStore = useWipeStore()
const toast = useToastStore() const toast = useToastStore()
@@ -135,288 +141,489 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="profiles">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3"> <div>
<FileText class="w-5 h-5 text-oxide-500" /> <div class="t-eyebrow">Operations</div>
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1> <h1 class="page__title">Wipe profiles</h1>
</div> </div>
<button <Button icon="plus" @click="openCreateModal">New profile</Button>
@click="openCreateModal" </div>
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"
<!-- 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" /> <template #action>
New Profile <Button icon="plus" size="sm" @click="openCreateModal">New profile</Button>
</button> </template>
</div> </EmptyState>
</Panel>
<!-- Profiles --> <!-- Profile list -->
<div v-if="wipeStore.profiles.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"> <div v-else class="profile-list">
<FileText class="w-10 h-10 text-neutral-600 mx-auto mb-3" /> <Panel
<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
v-for="profile in wipeStore.profiles" v-for="profile in wipeStore.profiles"
:key="profile.id" :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 <button
type="button"
class="profile-toggle"
@click="toggle(profile.id)" @click="toggle(profile.id)"
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
> >
<div> <div class="profile-meta">
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3> <span class="profile-name">{{ profile.profile_name }}</span>
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p> <span class="profile-desc">{{ profile.description || 'No description' }}</span>
</div> </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'">
</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<div class="flex items-center gap-1 pr-4"> <polyline points="6 9 12 15 18 9" />
<button </svg>
@click="openEditModal(profile)" </span>
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> </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> </div>
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4"> <!-- Expanded detail -->
<div class="grid grid-cols-2 gap-6"> <div v-if="expandedId === profile.id" class="profile-detail">
<div class="detail-grid">
<!-- Pre-wipe --> <!-- Pre-wipe -->
<div> <div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Pre-Wipe</h4> <div class="t-eyebrow detail-eyebrow">Pre-wipe</div>
<div class="space-y-2 text-sm"> <div class="detail-rows">
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Backup before wipe</span> <span class="detail-k">Backup before wipe</span>
<span :class="profile.pre_wipe_config.backup_before_wipe ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.pre_wipe_config.backup_before_wipe ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }} {{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Kick players</span> <span class="detail-k">Kick players</span>
<span :class="profile.pre_wipe_config.kick_players_before_wipe ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.pre_wipe_config.kick_players_before_wipe ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }} {{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Final save</span> <span class="detail-k">Final save</span>
<span :class="profile.pre_wipe_config.run_final_save ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.pre_wipe_config.run_final_save ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }} {{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Discord announce</span> <span class="detail-k">Discord announce</span>
<span :class="profile.pre_wipe_config.discord_pre_announce ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.pre_wipe_config.discord_pre_announce ? 'online' : 'neutral'">
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }} {{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
</div> </div>
</div> </div>
<!-- Post-wipe --> <!-- Post-wipe -->
<div> <div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Post-Wipe</h4> <div class="t-eyebrow detail-eyebrow">Post-wipe</div>
<div class="space-y-2 text-sm"> <div class="detail-rows">
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Verify server started</span> <span class="detail-k">Verify server started</span>
<span :class="profile.post_wipe_config.verify_server_started ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.post_wipe_config.verify_server_started ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }} {{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Verify plugins loaded</span> <span class="detail-k">Verify plugins loaded</span>
<span :class="profile.post_wipe_config.verify_plugins_loaded ? 'text-green-400' : 'text-neutral-600'"> <Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }} {{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
</span> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="detail-kv">
<span class="text-neutral-500">Rollback on failure</span> <span class="detail-k">Rollback on failure</span>
<span :class="profile.post_wipe_config.rollback_on_failure ? 'text-oxide-400' : 'text-neutral-600'"> <Badge :tone="profile.post_wipe_config.rollback_on_failure ? 'accent' : 'neutral'">
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }} {{ 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> </div>
<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>
</div> </div>
</div> </div>
</Panel>
</div> </div>
</div> </div>
<!-- Create / Edit Modal --> <!-- Create / Edit Modal -->
<Teleport to="body">
<div <div
v-if="showModal" v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" class="modal-backdrop"
@click.self="closeModal" @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"> <div class="modal">
<!-- Modal header --> <!-- Modal header -->
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between"> <div class="modal__head">
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2> <h2 class="modal__title">{{ editingProfile ? 'Edit profile' : 'New profile' }}</h2>
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"> <IconButton icon="x" label="Close" @click="closeModal" />
<X class="w-5 h-5" />
</button>
</div> </div>
<div class="p-6 space-y-6"> <div class="modal__body">
<!-- Basic Info --> <!-- Basic info -->
<div class="space-y-4"> <div class="form-section">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3> <div class="t-eyebrow form-section__eyebrow">Basic information</div>
<div> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
<input
v-model="form.profile_name" v-model="form.profile_name"
type="text" label="Profile name"
placeholder="Default Wipe Profile" 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" :required="true"
/> />
</div> <div class="cc-field">
<div> <span class="cc-field__label">Description (optional)</span>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<textarea <textarea
v-model="form.description" v-model="form.description"
rows="2" rows="2"
placeholder="Standard wipe configuration for monthly force wipes" 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" class="cc-textarea"
/> />
</div> </div>
</div> </div>
<!-- Pre-Wipe Config --> <!-- Pre-wipe config -->
<div class="space-y-4"> <div class="form-section">
<div class="flex items-center justify-between"> <div class="form-section__row">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3> <div class="t-eyebrow">Pre-wipe</div>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.enabled"
Enabled label="Enabled"
</label> />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="check-grid">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.backup_before_wipe"
Backup before wipe label="Backup before wipe"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.run_final_save"
Run final save label="Run final save"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.kick_players_before_wipe"
Kick players before wipe label="Kick players before wipe"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.discord_pre_announce"
Discord pre-announce label="Discord pre-announce"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.pre_wipe_config.pushbullet_notify"
Pushbullet notify label="Pushbullet notify"
</label> />
</div> </div>
<div v-if="form.pre_wipe_config.kick_players_before_wipe"> <Input
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label> v-if="form.pre_wipe_config.kick_players_before_wipe"
<input
v-model="form.pre_wipe_config.kick_message" v-model="form.pre_wipe_config.kick_message"
type="text" label="Kick message"
placeholder="Server is wiping, back soon!" 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>
</div>
<!-- Post-Wipe Config --> <!-- Post-wipe config -->
<div class="space-y-4"> <div class="form-section">
<div class="flex items-center justify-between"> <div class="form-section__row">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3> <div class="t-eyebrow">Post-wipe</div>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.enabled"
Enabled label="Enabled"
</label> />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="check-grid">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.verify_server_started"
Verify server started label="Verify server started"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.verify_correct_map"
Verify correct map label="Verify correct map"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.verify_plugins_loaded"
Verify plugins loaded label="Verify plugins loaded"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.verify_player_slots_open"
Verify player slots open label="Verify player slots open"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.rollback_on_failure"
Rollback on failure label="Rollback on failure"
</label> />
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer"> <Checkbox
<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" /> v-model="form.post_wipe_config.discord_post_announce"
Discord post-announce label="Discord post-announce"
</label> />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="number-grid">
<div> <div class="cc-field">
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label> <span class="cc-field__label">Max restart attempts</span>
<span class="cc-input cc-input--mono">
<input <input
v-model.number="form.post_wipe_config.max_restart_attempts" v-model.number="form.post_wipe_config.max_restart_attempts"
type="number" type="number"
min="1" min="1"
max="10" 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"
/> />
</span>
</div> </div>
<div> <div class="cc-field">
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label> <span class="cc-field__label">Health check timeout (s)</span>
<span class="cc-input cc-input--mono">
<input <input
v-model.number="form.post_wipe_config.health_check_timeout_seconds" v-model.number="form.post_wipe_config.health_check_timeout_seconds"
type="number" type="number"
min="30" min="30"
max="600" 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"
/> />
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal footer --> <!-- 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"> <div class="modal__foot">
<button <Button variant="secondary" @click="closeModal">Cancel</Button>
@click="closeModal" <Button
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors" :loading="isSaving"
>
Cancel
</button>
<button
@click="saveProfile" @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') }} {{ editingProfile ? 'Save changes' : 'Create profile' }}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
</Teleport>
</template> </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 { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { safeDate } from '@/utils/formatters' 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 wipeStore = useWipeStore()
const server = useServerStore() 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 () => { onMounted(async () => {
await wipeStore.fetchProfiles() await wipeStore.fetchProfiles()
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) { const first = wipeStore.profiles[0]
selectedProfileId.value = wipeStore.profiles[0].id if (first) {
selectedProfileId.value = first.id
} }
wipeStore.fetchSchedules() wipeStore.fetchSchedules()
wipeStore.fetchHistory() wipeStore.fetchHistory()
@@ -76,220 +104,413 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="p-6 space-y-6"> <div class="wipes">
<!-- Header --> <!-- Page head -->
<div class="flex items-center justify-between"> <div class="page__head">
<div class="flex items-center gap-3"> <div>
<RefreshCw class="w-5 h-5 text-oxide-500" /> <div class="t-eyebrow">Operations</div>
<h1 class="text-2xl font-bold text-neutral-100">Auto-Wiper</h1> <h1 class="page__title">Auto-wiper</h1>
</div> </div>
<div class="flex gap-2"> <div class="page__actions">
<RouterLink <RouterLink to="/wipes/profiles">
to="/wipes/profiles" <Button variant="secondary" size="sm" icon="file-text">Profiles</Button>
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Profiles
</RouterLink> </RouterLink>
<RouterLink <RouterLink to="/wipes/calendar">
to="/wipes/calendar" <Button variant="secondary" size="sm" icon="calendar">Calendar</Button>
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> </RouterLink>
<RouterLink <RouterLink to="/wipes/history">
to="/wipes/history" <Button variant="secondary" size="sm" icon="clock">History</Button>
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> </RouterLink>
</div> </div>
</div> </div>
<!-- Manual Trigger --> <!-- Manual trigger -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Manual wipe" subtitle="Trigger an immediate wipe outside the schedule">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Manual Wipe</h2> <div class="trigger-body">
<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"> <Alert
<AlertTriangle class="w-4 h-4 shrink-0" /> v-if="wipeStore.profiles.length === 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. tone="warn"
</div> title="No wipe profiles"
<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'"
> >
{{ opt }} <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> </button>
</div> </div>
</div> </div>
<div>
<label class="block text-xs text-neutral-500 mb-2">Profile</label> <!-- Profile select -->
<select <Select
v-model="selectedProfileId" label="Profile"
:options="profileOptions()"
:disabled="wipeStore.profiles.length === 0" :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" :model-value="selectedProfileId"
> @update:model-value="selectedProfileId = $event ?? ''"
<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>
<!-- Dry-Run Results --> <div class="trigger-actions">
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Button
<div class="flex items-center justify-between mb-4"> variant="secondary"
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2> size="md"
<div class="flex items-center gap-3"> icon="flask-conical"
<span class="text-xs text-neutral-500"> :loading="dryRunLoading"
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s :disabled="wipeStore.profiles.length === 0"
</span> @click="triggerDryRun"
<button
@click="dryRunResult = null"
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
> >
<X class="w-4 h-4" /> Dry run
</button> </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> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> </div>
</Panel>
<!-- 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> <div>
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5"> <div class="dry-run__head dry-run__head--delete">
<X class="w-3.5 h-3.5" /> Would delete ({{ dryRunResult.would_delete.length }})
Would Delete ({{ dryRunResult.would_delete.length }}) </div>
</p> <div v-if="dryRunResult.would_delete.length === 0" class="dry-run__empty">
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div> Nothing to delete
<ul v-else class="space-y-1"> </div>
<ul v-else class="dry-run__list">
<li <li
v-for="item in dryRunResult.would_delete" v-for="item in dryRunResult.would_delete"
:key="item" :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> >{{ item }}</li>
</ul> </ul>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5"> <div class="dry-run__head dry-run__head--keep">
<Check class="w-3.5 h-3.5" /> Would preserve ({{ dryRunResult.would_preserve.length }})
Would Preserve ({{ dryRunResult.would_preserve.length }}) </div>
</p> <div v-if="dryRunResult.would_preserve.length === 0" class="dry-run__empty">
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div> Nothing preserved
<ul v-else class="space-y-1"> </div>
<ul v-else class="dry-run__list">
<li <li
v-for="item in dryRunResult.would_preserve" v-for="item in dryRunResult.would_preserve"
:key="item" :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> >{{ item }}</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </Panel>
<!-- Upcoming Schedules --> <!-- Scheduled wipes -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel title="Scheduled wipes" subtitle="Active cron schedules">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Scheduled Wipes</h2> <EmptyState
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 py-4 text-center"> v-if="wipeStore.schedules.length === 0"
No wipe schedules configured. Create a profile and schedule to automate wipes. icon="calendar-clock"
</div> title="No schedules"
<div v-else class="space-y-3"> description="Create a profile and schedule to automate wipes."
/>
<div v-else class="sched-list">
<div <div
v-for="schedule in wipeStore.schedules" v-for="schedule in wipeStore.schedules"
:key="schedule.id" :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"> <div class="sched-info">
<Clock class="w-4 h-4 text-neutral-500" /> <div class="sched-name">{{ schedule.schedule_name }}</div>
<div> <div class="sched-meta">
<p class="text-sm font-medium text-neutral-200">{{ schedule.schedule_name }}</p> {{ schedule.wipe_type }} wipe
<p class="text-xs text-neutral-500"> &middot;
{{ schedule.wipe_type }} wipe &middot; {{ schedule.cron_expression }} ({{ schedule.timezone }}) <span class="mono">{{ schedule.cron_expression }}</span>
</p> &middot; {{ schedule.timezone }}
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="sched-controls">
<span <Badge :tone="schedule.is_active ? 'online' : 'neutral'">
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'"
>
{{ schedule.is_active ? 'Active' : 'Paused' }} {{ schedule.is_active ? 'Active' : 'Paused' }}
</span> </Badge>
<button <Switch
@click="toggleSchedule(schedule.id, schedule.is_active)" :model-value="schedule.is_active"
:disabled="scheduleToggling === schedule.id" :disabled="scheduleToggling === schedule.id"
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer" @update:model-value="toggleSchedule(schedule.id, schedule.is_active)"
: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>
</div>
</div> </div>
</div> </div>
</div> </div>
</Panel>
<!-- Recent History --> <!-- Recent history -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"> <Panel :flush-body="true" title="Recent wipes">
<div class="flex items-center justify-between mb-4"> <template #actions>
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Recent Wipes</h2> <RouterLink to="/wipes/history">
<RouterLink to="/wipes/history" class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors"> <Button variant="ghost" size="sm">View all</Button>
View All
</RouterLink> </RouterLink>
</div> </template>
<div v-if="wipeStore.history.length === 0" class="text-sm text-neutral-500 py-4 text-center">
No wipe history yet. <EmptyState
</div> v-if="wipeStore.history.length === 0"
<div v-else class="space-y-2"> icon="trash-2"
<div 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)" v-for="wipe in wipeStore.history.slice(0, 5)"
:key="wipe.id" :key="wipe.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
> >
<div> <td class="td-primary">{{ wipe.wipe_type }} wipe</td>
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p> <td>{{ wipe.trigger_type }}</td>
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} &middot; {{ safeDate(wipe.started_at, 'Pending') }}</p> <td class="td-mono">{{ safeDate(wipe.started_at, 'Pending') }}</td>
</div> <td>
<span <Badge :tone="wipeTone(wipe.status)">{{ wipe.status }}</Badge>
class="text-xs font-medium px-2 py-0.5 rounded-full" </td>
:class="{ </tr>
'bg-green-500/10 text-green-400': wipe.status === 'success', </tbody>
'bg-red-500/10 text-red-400': wipe.status === 'failed' || wipe.status === 'rolled_back', </table>
'bg-yellow-500/10 text-yellow-400': wipe.status === 'wiping' || wipe.status === 'pre_wipe' || wipe.status === 'post_wipe', </Panel>
'bg-neutral-700/50 text-neutral-400': wipe.status === 'pending',
}"
>
{{ wipe.status }}
</span>
</div>
</div>
</div>
</div> </div>
</template> </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>