feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,9 @@ import {
|
||||
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
|
||||
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
|
||||
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
|
||||
Ban, Flag,
|
||||
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||
Pencil, Save, ShoppingBag, Target, User,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -50,6 +53,11 @@ const registry: Record<string, Component> = {
|
||||
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
|
||||
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
|
||||
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft,
|
||||
ban: Ban, flag: Flag,
|
||||
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
|
||||
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||
target: Target, user: User,
|
||||
}
|
||||
|
||||
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { AlertTriangle, Save, Loader2 } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface AlertConfig {
|
||||
population_drop_enabled: boolean
|
||||
@@ -62,15 +68,29 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'info': return 'bg-blue-500/10 text-blue-400'
|
||||
case 'warning': return 'bg-yellow-500/10 text-yellow-400'
|
||||
case 'critical': return 'bg-red-500/10 text-red-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
function severityTone(severity: string): 'info' | 'warn' | 'offline' | 'neutral' {
|
||||
if (severity === 'info') return 'info'
|
||||
if (severity === 'warning') return 'warn'
|
||||
if (severity === 'critical') return 'offline'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// Range input handler (range emits string, keep numeric in config)
|
||||
function onThresholdInput(e: Event) {
|
||||
const val = parseInt((e.target as HTMLInputElement).value, 10)
|
||||
if (!isNaN(val)) {
|
||||
config.value.population_drop_threshold_percent = val
|
||||
}
|
||||
}
|
||||
|
||||
// FPS threshold: DS Input binds string; sync via a string ref
|
||||
const fpsStr = ref(String(config.value.fps_threshold))
|
||||
function onFpsUpdate(val: string | undefined) {
|
||||
fpsStr.value = val ?? ''
|
||||
const n = parseInt(val ?? '', 10)
|
||||
if (!isNaN(n)) config.value.fps_threshold = n
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
fetchHistory()
|
||||
@@ -78,156 +98,258 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Alerts</h1>
|
||||
</div>
|
||||
|
||||
<!-- Alert Configuration -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Alert Configuration</h2>
|
||||
|
||||
<div v-if="isLoading" class="py-8 flex justify-center">
|
||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Population Drop Alert -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-neutral-200">Population Drop Alert</label>
|
||||
<button
|
||||
@click="config.population_drop_enabled = !config.population_drop_enabled"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="config.population_drop_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="config.population_drop_enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="config.population_drop_enabled">
|
||||
<label class="block text-xs text-neutral-500 mb-2">Threshold (%)</label>
|
||||
<input
|
||||
v-model.number="config.population_drop_threshold_percent"
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
class="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-oxide-500"
|
||||
/>
|
||||
<div class="text-xs text-neutral-400 mt-1">{{ config.population_drop_threshold_percent }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FPS Degradation Alert -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-neutral-200">FPS Degradation Alert</label>
|
||||
<button
|
||||
@click="config.fps_degradation_enabled = !config.fps_degradation_enabled"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="config.fps_degradation_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="config.fps_degradation_enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="config.fps_degradation_enabled">
|
||||
<label class="block text-xs text-neutral-500 mb-2">FPS Threshold</label>
|
||||
<input
|
||||
v-model.number="config.fps_threshold"
|
||||
type="number"
|
||||
min="10"
|
||||
max="60"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Channels -->
|
||||
<div class="border-t border-neutral-800 pt-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Notification Channels</h3>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="config.notify_discord"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-200">Discord</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="config.notify_pushbullet"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-200">Pushbullet</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="config.notify_email"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
||||
/>
|
||||
<span class="text-sm text-neutral-200">Email</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
@click="saveConfig"
|
||||
:disabled="isSaving"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isSaving" class="w-4 h-4 animate-spin" />
|
||||
<Save v-else class="w-4 h-4" />
|
||||
Save Configuration
|
||||
</button>
|
||||
<div class="alerts">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Monitoring</div>
|
||||
<h1 class="page__title">Alerts</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert History -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="p-5 border-b border-neutral-800">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Alert History</h2>
|
||||
<!-- Alert configuration -->
|
||||
<Panel title="Alert configuration" subtitle="Trigger conditions and notification channels">
|
||||
<div v-if="isLoading" class="loading-row">
|
||||
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
|
||||
</div>
|
||||
<div v-if="history.length === 0" class="p-8 text-center text-neutral-500">
|
||||
No alerts triggered yet.
|
||||
|
||||
<div v-else class="config-body">
|
||||
<!-- Triggers section -->
|
||||
<div class="config-section">
|
||||
<div class="t-eyebrow config-section__eyebrow">Triggers</div>
|
||||
|
||||
<!-- Population drop -->
|
||||
<div class="alert-rule">
|
||||
<div class="alert-rule__head">
|
||||
<div>
|
||||
<div class="alert-rule__name">Population drop alert</div>
|
||||
<div class="alert-rule__desc">Fire when player count falls by this percentage</div>
|
||||
</div>
|
||||
<Switch v-model="config.population_drop_enabled" />
|
||||
</div>
|
||||
<div v-if="config.population_drop_enabled" class="alert-rule__body">
|
||||
<div class="range-field">
|
||||
<input
|
||||
type="range"
|
||||
:value="config.population_drop_threshold_percent"
|
||||
min="10"
|
||||
max="100"
|
||||
class="cc-range"
|
||||
@input="onThresholdInput"
|
||||
/>
|
||||
<span class="range-value">{{ config.population_drop_threshold_percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FPS degradation -->
|
||||
<div class="alert-rule">
|
||||
<div class="alert-rule__head">
|
||||
<div>
|
||||
<div class="alert-rule__name">FPS degradation alert</div>
|
||||
<div class="alert-rule__desc">Fire when server FPS drops below threshold</div>
|
||||
</div>
|
||||
<Switch v-model="config.fps_degradation_enabled" />
|
||||
</div>
|
||||
<div v-if="config.fps_degradation_enabled" class="alert-rule__body">
|
||||
<Input
|
||||
:model-value="fpsStr"
|
||||
label="FPS threshold"
|
||||
type="number"
|
||||
:mono="true"
|
||||
hint="Minimum acceptable FPS (10–60)"
|
||||
@update:model-value="onFpsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification channels -->
|
||||
<div class="config-section">
|
||||
<div class="t-eyebrow config-section__eyebrow">Notification channels</div>
|
||||
<div class="channels">
|
||||
<Checkbox v-model="config.notify_discord" label="Discord" />
|
||||
<Checkbox v-model="config.notify_pushbullet" label="Pushbullet" />
|
||||
<Checkbox v-model="config.notify_email" label="Email" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save -->
|
||||
<Button icon="save" :loading="isSaving" @click="saveConfig">
|
||||
Save configuration
|
||||
</Button>
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
||||
</Panel>
|
||||
|
||||
<!-- Alert history -->
|
||||
<Panel :flush-body="true" title="Alert history">
|
||||
<EmptyState
|
||||
v-if="history.length === 0"
|
||||
icon="triangle-alert"
|
||||
title="No alerts triggered"
|
||||
description="Alerts will appear here when trigger conditions are met."
|
||||
/>
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Triggered</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Severity</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Title</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Message</th>
|
||||
<th>Triggered</th>
|
||||
<th>Type</th>
|
||||
<th>Severity</th>
|
||||
<th>Title</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
||||
:class="getSeverityColor(alert.severity)"
|
||||
>
|
||||
{{ alert.severity }}
|
||||
</span>
|
||||
<tbody>
|
||||
<tr v-for="entry in history" :key="entry.id">
|
||||
<td class="td-mono">{{ safeDate(entry.triggered_at) }}</td>
|
||||
<td>{{ entry.alert_type }}</td>
|
||||
<td>
|
||||
<Badge :tone="severityTone(entry.severity)">{{ entry.severity }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ alert.title }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.message }}</td>
|
||||
<td class="td-primary">{{ entry.title }}</td>
|
||||
<td>{{ entry.message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alerts {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Loading row */
|
||||
.loading-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Config body */
|
||||
.config-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.config-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.config-section__eyebrow { margin-bottom: 4px; }
|
||||
|
||||
/* Alert rule card */
|
||||
.alert-rule {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.alert-rule__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 15px;
|
||||
}
|
||||
.alert-rule__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.alert-rule__desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.alert-rule__body {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 13px 15px;
|
||||
}
|
||||
|
||||
/* Range slider */
|
||||
.range-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.cc-range {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
appearance: none;
|
||||
background: var(--surface-active);
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
outline: 0;
|
||||
}
|
||||
.range-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Channels */
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
@@ -8,6 +7,11 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
@@ -54,9 +58,22 @@ const loadAnalytics = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!timeseries.value) return
|
||||
|
||||
const accent = cssVar('--accent') || '#CE422B'
|
||||
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||
const axisLine = cssVar('--border-default') || '#404040'
|
||||
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
|
||||
// Player count chart
|
||||
if (playerChart.value) {
|
||||
if (playerChartInstance) {
|
||||
@@ -68,9 +85,9 @@ const renderCharts = () => {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' }
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
@@ -86,14 +103,14 @@ const renderCharts = () => {
|
||||
day: 'numeric',
|
||||
hour: '2-digit'
|
||||
})),
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -101,14 +118,14 @@ const renderCharts = () => {
|
||||
type: 'line',
|
||||
data: timeseries.value.player_count,
|
||||
smooth: true,
|
||||
lineStyle: { color: '#CE422B', width: 2 },
|
||||
lineStyle: { color: accent, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(206, 66, 43, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(206, 66, 43, 0)' }
|
||||
{ offset: 0, color: accent + '55' },
|
||||
{ offset: 1, color: accent + '00' }
|
||||
])
|
||||
},
|
||||
itemStyle: { color: '#CE422B' }
|
||||
itemStyle: { color: accent }
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -125,13 +142,13 @@ const renderCharts = () => {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' }
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||
},
|
||||
legend: {
|
||||
data: ['FPS', 'Entities'],
|
||||
textStyle: { color: '#a3a3a3' },
|
||||
textStyle: { color: labelColor, fontFamily: mono },
|
||||
top: 0
|
||||
},
|
||||
grid: {
|
||||
@@ -148,25 +165,25 @@ const renderCharts = () => {
|
||||
day: 'numeric',
|
||||
hour: '2-digit'
|
||||
})),
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'FPS',
|
||||
position: 'left',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Entities',
|
||||
position: 'right',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
@@ -223,114 +240,209 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="analytics-view">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<BarChart3 class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="downloadCSV"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
<div class="analytics-view__header">
|
||||
<h1 class="analytics-view__title">Analytics</h1>
|
||||
<div class="analytics-view__controls">
|
||||
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||
Export CSV
|
||||
</button>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['24h', '7d', '30d'] as const)"
|
||||
:key="opt"
|
||||
@click="timeRange = opt"
|
||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Tabs
|
||||
:items="(['24h', '7d', '30d'] as const).map(v => ({ value: v, label: v }))"
|
||||
v-model="timeRange"
|
||||
variant="pill"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading analytics...</div>
|
||||
<div v-if="loading" class="analytics-view__loading">
|
||||
<span class="analytics-view__loading-text">Loading analytics...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="summary">
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Users class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Peak Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.peak_players }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Avg Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Uptime</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Unique Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.unique_players ?? '—' }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Phase 2.2</p>
|
||||
</div>
|
||||
<div class="analytics-view__stats">
|
||||
<StatCard
|
||||
label="Peak players"
|
||||
:value="summary.peak_players ?? '—'"
|
||||
icon="users"
|
||||
:note="`Last ${timeRange}`"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg players"
|
||||
:value="safeFixed(summary?.avg_players, 1)"
|
||||
icon="trending-up"
|
||||
:note="`Last ${timeRange}`"
|
||||
/>
|
||||
<StatCard
|
||||
label="Uptime"
|
||||
:value="safeFixed(summary?.uptime_percentage, 1)"
|
||||
unit="%"
|
||||
icon="clock"
|
||||
:note="`Last ${timeRange}`"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unique players"
|
||||
:value="summary.unique_players ?? '—'"
|
||||
icon="bar-chart-3"
|
||||
note="Phase 2.2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Count Over Time</h2>
|
||||
<div ref="playerChart" class="h-64"></div>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Server Performance</h2>
|
||||
<div ref="perfChart" class="h-64"></div>
|
||||
</div>
|
||||
<div class="analytics-view__charts">
|
||||
<Panel title="Player count over time">
|
||||
<div ref="playerChart" class="analytics-view__chart-area"></div>
|
||||
</Panel>
|
||||
<Panel title="Server performance">
|
||||
<div ref="perfChart" class="analytics-view__chart-area"></div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Player Retention -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
||||
<!-- Player Retention placeholder -->
|
||||
<Panel eyebrow="Coming in phase 2" title="Player retention">
|
||||
<template #title-append>
|
||||
<Badge tone="neutral">Phase 2</Badge>
|
||||
</template>
|
||||
<div class="analytics-view__retention-grid">
|
||||
<div class="analytics-view__retention-cell">
|
||||
<p class="analytics-view__retention-label">New players</p>
|
||||
<p class="analytics-view__retention-value">—</p>
|
||||
<p class="analytics-view__retention-note">First-time visitors</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
||||
<div class="analytics-view__retention-cell">
|
||||
<p class="analytics-view__retention-label">Returning players</p>
|
||||
<p class="analytics-view__retention-value">—</p>
|
||||
<p class="analytics-view__retention-note">Seen more than once</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
||||
<div class="analytics-view__retention-cell">
|
||||
<p class="analytics-view__retention-label">Avg session duration</p>
|
||||
<p class="analytics-view__retention-value">—</p>
|
||||
<p class="analytics-view__retention-note">Per visit</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
||||
</div>
|
||||
<p class="analytics-view__retention-footer">
|
||||
Player retention analytics will be available in phase 2.
|
||||
</p>
|
||||
</Panel>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analytics-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.analytics-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.analytics-view__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analytics-view__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.analytics-view__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.analytics-view__loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.analytics-view__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analytics-view__stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-view__charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analytics-view__charts {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-view__chart-area {
|
||||
height: 256px;
|
||||
}
|
||||
|
||||
.analytics-view__retention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analytics-view__retention-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-view__retention-cell {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analytics-view__retention-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.analytics-view__retention-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.analytics-view__retention-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.analytics-view__retention-footer {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ChatMessage } from '@/types'
|
||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
@@ -32,12 +39,18 @@ const filteredMessages = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
function channelBadgeClass(channel: string): string {
|
||||
const channelTabItems = computed(() => [
|
||||
{ value: 'all', label: 'All', count: messages.value.length },
|
||||
{ value: 'global', label: 'Global' },
|
||||
{ value: 'team', label: 'Team' },
|
||||
{ value: 'server', label: 'Server' },
|
||||
])
|
||||
|
||||
function channelTone(channel: string): 'accent' | 'info' | 'neutral' {
|
||||
switch (channel) {
|
||||
case 'global': return 'bg-oxide-500/15 text-oxide-400'
|
||||
case 'team': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'server': return 'bg-neutral-700/50 text-neutral-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
case 'global': return 'accent'
|
||||
case 'team': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,89 +89,163 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<MessageSquare class="w-5 h-5 text-oxide-500" />
|
||||
<div class="clv">
|
||||
<!-- Page head -->
|
||||
<div class="clv__head">
|
||||
<div class="clv__head-id">
|
||||
<div class="clv__head-chip">
|
||||
<Icon name="message-square" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Chat Log</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">{{ messages.length }} messages</p>
|
||||
<div class="t-eyebrow">Chat log</div>
|
||||
<h1 class="clv__title">Chat log</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="fetchMessages"
|
||||
:disabled="isLoading"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
<div class="clv__head-actions">
|
||||
<div class="clv__stat-pill">
|
||||
<span class="clv__stat-num">{{ messages.length }}</span>
|
||||
<span class="clv__stat-label">messages</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="fetchMessages"
|
||||
>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search messages, players, or Steam IDs..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['all', 'global', 'team', 'server'] as const)"
|
||||
:key="opt"
|
||||
@click="channelFilter = opt"
|
||||
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
||||
:class="channelFilter === opt
|
||||
? 'bg-oxide-500/15 text-oxide-400'
|
||||
: 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="clv__filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search messages, players, or Steam IDs…"
|
||||
size="sm"
|
||||
style="max-width: 340px;"
|
||||
/>
|
||||
<Tabs v-model="channelFilter" :items="channelTabItems" />
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg divide-y divide-neutral-800">
|
||||
<div v-if="filteredMessages.length === 0" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading chat messages...</template>
|
||||
<template v-else-if="searchQuery">No messages matching "{{ searchQuery }}"</template>
|
||||
<template v-else>No chat messages yet. Messages will appear when the server is active.</template>
|
||||
<!-- Messages panel -->
|
||||
<Panel :flush-body="true">
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
v-if="filteredMessages.length === 0 && !isLoading"
|
||||
icon="message-square"
|
||||
:title="searchQuery ? 'No messages found' : 'No chat messages'"
|
||||
:description="searchQuery
|
||||
? `No messages matching "${searchQuery}".`
|
||||
: 'No chat messages yet. Messages will appear when the server is active.'"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoading && filteredMessages.length === 0" class="clv__loading">
|
||||
<Icon name="loader" :size="20" class="clv__spin" />
|
||||
<span>Loading chat messages…</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in filteredMessages"
|
||||
:key="msg.id"
|
||||
class="flex items-start gap-4 px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
||||
:class="{ 'bg-red-500/5 border-l-2 border-l-red-500/30': msg.flagged }"
|
||||
>
|
||||
<div class="shrink-0 text-right w-20">
|
||||
<p class="text-xs text-neutral-500">{{ formatDate(msg.created_at) }}</p>
|
||||
<p class="text-xs text-neutral-600">{{ formatTime(msg.created_at) }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full mt-0.5"
|
||||
:class="channelBadgeClass(msg.channel)"
|
||||
|
||||
<!-- Message list -->
|
||||
<div v-else class="clv__messages">
|
||||
<div
|
||||
v-for="msg in filteredMessages"
|
||||
:key="msg.id"
|
||||
class="clv__row"
|
||||
:class="{ 'clv__row--flagged': msg.flagged }"
|
||||
>
|
||||
{{ msg.channel }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-oxide-400">{{ msg.player_name }}</span>
|
||||
<span class="text-sm text-neutral-500 ml-2 font-mono">{{ msg.steam_id }}</span>
|
||||
<p class="text-sm text-neutral-300 mt-0.5 break-words">{{ msg.message }}</p>
|
||||
<!-- Timestamp -->
|
||||
<div class="clv__ts">
|
||||
<span class="clv__date">{{ formatDate(msg.created_at) }}</span>
|
||||
<span class="clv__time">{{ formatTime(msg.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Channel badge -->
|
||||
<Badge :tone="channelTone(msg.channel)" size="md">{{ msg.channel }}</Badge>
|
||||
|
||||
<!-- Message body -->
|
||||
<div class="clv__body">
|
||||
<span class="clv__player">{{ msg.player_name }}</span>
|
||||
<span class="clv__steam">{{ msg.steam_id }}</span>
|
||||
<p class="clv__text">{{ msg.message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flag toggle -->
|
||||
<IconButton
|
||||
icon="bookmark"
|
||||
:variant="msg.flagged ? 'accent' : 'ghost'"
|
||||
size="sm"
|
||||
:label="msg.flagged ? 'Unflag message' : 'Flag message'"
|
||||
@click="toggleFlag(msg)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleFlag(msg)"
|
||||
class="shrink-0 p-1 rounded transition-colors"
|
||||
:class="msg.flagged ? 'text-red-400 hover:text-red-300' : 'text-neutral-600 hover:text-neutral-400'"
|
||||
:title="msg.flagged ? 'Unflag message' : 'Flag message'"
|
||||
>
|
||||
<Flag class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clv { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.clv__head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.clv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.clv__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.clv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.clv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Stat pill */
|
||||
.clv__stat-pill { display: flex; align-items: center; gap: 6px; }
|
||||
.clv__stat-num { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.clv__stat-label { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
/* Filters */
|
||||
.clv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* Loading */
|
||||
.clv__loading {
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||
}
|
||||
@keyframes clv-spin { to { transform: rotate(360deg); } }
|
||||
.clv__spin { animation: clv-spin 0.7s linear infinite; }
|
||||
|
||||
/* Messages */
|
||||
.clv__messages { display: flex; flex-direction: column; }
|
||||
.clv__row {
|
||||
display: flex; align-items: flex-start; gap: 14px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.clv__row:last-child { border-bottom: 0; }
|
||||
.clv__row:hover { background: var(--surface-hover); }
|
||||
.clv__row--flagged {
|
||||
background: var(--status-offline-soft);
|
||||
border-left: 3px solid var(--status-offline-border);
|
||||
}
|
||||
.clv__row--flagged:hover { background: var(--status-offline-soft); filter: brightness(1.04); }
|
||||
|
||||
/* Timestamp */
|
||||
.clv__ts { display: flex; flex-direction: column; align-items: flex-end; min-width: 68px; flex: none; padding-top: 1px; }
|
||||
.clv__date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.clv__time { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
|
||||
|
||||
/* Message body */
|
||||
.clv__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.clv__player { font-size: var(--text-sm); font-weight: 600; color: var(--accent-text); }
|
||||
.clv__steam { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-left: 8px; }
|
||||
.clv__text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; word-break: break-word; margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MapAnalyticsSummary } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -30,6 +36,10 @@ const loadMapAnalytics = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!analytics.value || analytics.value.maps.length === 0) return
|
||||
|
||||
@@ -44,20 +54,29 @@ const renderCharts = () => {
|
||||
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
|
||||
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
|
||||
|
||||
const accent = cssVar('--accent') || '#CE422B'
|
||||
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||
const axisLine = cssVar('--border-default') || '#404040'
|
||||
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
|
||||
performanceChartInstance.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['Avg Players', 'Peak Players'],
|
||||
textStyle: { color: '#a3a3a3' },
|
||||
textStyle: { color: labelColor, fontFamily: mono },
|
||||
top: 0
|
||||
},
|
||||
grid: {
|
||||
@@ -70,22 +89,22 @@ const renderCharts = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: mapNames,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Players',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Avg Players',
|
||||
type: 'bar',
|
||||
data: avgPlayers,
|
||||
itemStyle: { color: '#CE422B' },
|
||||
itemStyle: { color: accent },
|
||||
barGap: '10%'
|
||||
},
|
||||
{
|
||||
@@ -138,182 +157,263 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="map-analytics-view">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Map class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="downloadCSV"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
<div class="map-analytics-view__header">
|
||||
<h1 class="map-analytics-view__title">Map analytics</h1>
|
||||
<div class="map-analytics-view__controls">
|
||||
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||
Export CSV
|
||||
</button>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['30d', '90d', 'all'] as const)"
|
||||
:key="opt"
|
||||
@click="timeRange = opt"
|
||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Tabs
|
||||
:items="[
|
||||
{ value: '30d', label: '30d' },
|
||||
{ value: '90d', label: '90d' },
|
||||
{ value: 'all', label: 'All' }
|
||||
]"
|
||||
v-model="timeRange"
|
||||
variant="pill"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading map analytics...</div>
|
||||
<div v-if="loading" class="map-analytics-view__loading">
|
||||
<span class="map-analytics-view__loading-text">Loading map analytics...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="analytics">
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Award class="w-4 h-4 text-oxide-400" />
|
||||
<p class="text-sm text-neutral-400">Best Performing Map</p>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-neutral-100">
|
||||
{{ analytics.best_performing_map ?? 'No data' }}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0">
|
||||
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Target class="w-4 h-4 text-green-400" />
|
||||
<p class="text-sm text-neutral-400">Rotation Effectiveness</p>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-neutral-100">
|
||||
{{ safeFixed(analytics?.rotation_effectiveness, 1) }}%
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TrendingUp class="w-4 h-4 text-blue-400" />
|
||||
<p class="text-sm text-neutral-400">Total Maps Tracked</p>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-neutral-100">{{ analytics.maps.length }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="map-analytics-view__stats">
|
||||
<StatCard
|
||||
label="Best performing map"
|
||||
:value="analytics.best_performing_map ?? 'No data'"
|
||||
icon="award"
|
||||
:note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
|
||||
/>
|
||||
<StatCard
|
||||
label="Rotation effectiveness"
|
||||
:value="safeFixed(analytics?.rotation_effectiveness, 1)"
|
||||
unit="%"
|
||||
icon="target"
|
||||
note="Overall rotation health"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total maps tracked"
|
||||
:value="analytics.maps.length"
|
||||
icon="trending-up"
|
||||
:note="`Last ${timeRange}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Performance chart -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Map Performance Comparison
|
||||
</h2>
|
||||
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div>
|
||||
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
||||
<p class="text-sm text-neutral-600">No map data available for this time range</p>
|
||||
</div>
|
||||
</div>
|
||||
<Panel title="Map performance comparison">
|
||||
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="map"
|
||||
title="No map data"
|
||||
description="No map data available for this time range."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Map performance table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Detailed Map Metrics
|
||||
</h2>
|
||||
<div v-if="sortedMaps.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-neutral-800">
|
||||
<tr>
|
||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th>
|
||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th>
|
||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th>
|
||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Avg Players</th>
|
||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Peak Players</th>
|
||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Effectiveness</th>
|
||||
<Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
|
||||
<div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
|
||||
<table class="map-analytics-view__table">
|
||||
<thead>
|
||||
<tr class="map-analytics-view__thead-row">
|
||||
<th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
|
||||
<th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
|
||||
<th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
|
||||
<th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
|
||||
<th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
|
||||
<th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="map in sortedMaps"
|
||||
:key="map.map_id"
|
||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30 transition-colors"
|
||||
class="map-analytics-view__row"
|
||||
>
|
||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ map.map_name }}</td>
|
||||
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td>
|
||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td>
|
||||
<td class="py-3 px-4 text-right text-neutral-300">{{ safeFixed(map.avg_players, 1) }}</td>
|
||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
|
||||
:class="{
|
||||
'bg-green-500/10 text-green-400': map.effectiveness_score >= 80,
|
||||
'bg-yellow-500/10 text-yellow-400': map.effectiveness_score >= 60 && map.effectiveness_score < 80,
|
||||
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
|
||||
}"
|
||||
<td class="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
|
||||
<td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
|
||||
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
|
||||
<td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
|
||||
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
|
||||
<td class="map-analytics-view__td map-analytics-view__td--right">
|
||||
<Badge
|
||||
:tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
|
||||
mono
|
||||
>
|
||||
{{ safeFixed(map.effectiveness_score, 1) }}%
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
||||
<p class="text-sm text-neutral-600">No map data available</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="map"
|
||||
title="No map data"
|
||||
description="No map data available."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Insights section -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Actionable Insights
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg">
|
||||
<Award class="w-5 h-5 text-oxide-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200 font-medium">
|
||||
Your best map is <span class="text-oxide-400">{{ analytics.best_performing_map }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Panel title="Actionable insights">
|
||||
<div class="map-analytics-view__insights">
|
||||
<Alert
|
||||
v-if="analytics.best_performing_map"
|
||||
tone="accent"
|
||||
:title="`Best map: ${analytics.best_performing_map}`"
|
||||
>
|
||||
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
||||
</Alert>
|
||||
|
||||
<div
|
||||
<Alert
|
||||
v-if="analytics.rotation_effectiveness < 70"
|
||||
class="flex items-start gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"
|
||||
tone="warn"
|
||||
title="Rotation effectiveness is below optimal"
|
||||
>
|
||||
<Target class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200 font-medium">Rotation effectiveness is below optimal</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
||||
</Alert>
|
||||
|
||||
<div
|
||||
<Alert
|
||||
v-else-if="analytics.rotation_effectiveness >= 80"
|
||||
class="flex items-start gap-3 p-3 bg-green-500/5 border border-green-500/20 rounded-lg"
|
||||
tone="online"
|
||||
title="Excellent rotation effectiveness"
|
||||
>
|
||||
<TrendingUp class="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200 font-medium">Excellent rotation effectiveness!</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-analytics-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.map-analytics-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.map-analytics-view__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.map-analytics-view__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.map-analytics-view__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.map-analytics-view__loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.map-analytics-view__stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.map-analytics-view__stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.map-analytics-view__chart-area {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.map-analytics-view__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.map-analytics-view__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.map-analytics-view__thead-row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.map-analytics-view__th {
|
||||
padding: 12px 16px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-analytics-view__th--left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.map-analytics-view__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-analytics-view__row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.map-analytics-view__row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.map-analytics-view__td {
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.map-analytics-view__td--primary {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.map-analytics-view__td--num {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.map-analytics-view__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-analytics-view__insights {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { MapEntry } from '@/types'
|
||||
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import { safeFileSize } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
@@ -20,8 +24,8 @@ function formatSize(bytes: number): string {
|
||||
return safeFileSize(bytes)
|
||||
}
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400'
|
||||
function mapTypeTone(type: string): 'accent' | 'info' {
|
||||
return type === 'custom' ? 'accent' : 'info'
|
||||
}
|
||||
|
||||
async function fetchMaps() {
|
||||
@@ -92,84 +96,211 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Map class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p>
|
||||
</div>
|
||||
<div class="maps">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Operations</div>
|
||||
<h1 class="page__title">
|
||||
Map library
|
||||
<span v-if="maps.length > 0" class="page__count">{{ maps.length }} maps</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="fetchMaps"
|
||||
<div class="page__actions">
|
||||
<IconButton
|
||||
icon="refresh-cw"
|
||||
label="Refresh"
|
||||
:class="isLoading && 'spin'"
|
||||
:disabled="isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
@click="fetchMaps"
|
||||
/>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".map"
|
||||
class="hidden"
|
||||
class="hidden-input"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
icon="upload"
|
||||
:loading="isUploading"
|
||||
@click="triggerFileInput"
|
||||
:disabled="isUploading"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
|
||||
</button>
|
||||
{{ isUploading ? 'Uploading…' : 'Upload map' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maps grid -->
|
||||
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Map class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Maps</h3>
|
||||
<p class="text-sm text-neutral-500">Upload custom maps or they'll appear here when procedural maps are generated.</p>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<Panel v-if="maps.length === 0">
|
||||
<EmptyState
|
||||
icon="map"
|
||||
title="No maps"
|
||||
description="Upload custom maps or they will appear here when procedural maps are generated."
|
||||
>
|
||||
<template #action>
|
||||
<Button icon="upload" size="sm" @click="triggerFileInput">Upload map</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
|
||||
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Maps grid -->
|
||||
<div v-else class="maps-grid">
|
||||
<div
|
||||
v-for="map in maps"
|
||||
:key="map.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-neutral-700 transition-colors"
|
||||
class="map-card"
|
||||
>
|
||||
<!-- Thumbnail or placeholder -->
|
||||
<div class="h-32 bg-neutral-800 flex items-center justify-center">
|
||||
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" />
|
||||
<Map v-else class="w-8 h-8 text-neutral-600" />
|
||||
<!-- Thumbnail -->
|
||||
<div class="map-card__thumb">
|
||||
<img
|
||||
v-if="map.thumbnail_path"
|
||||
:src="map.thumbnail_path"
|
||||
:alt="map.display_name"
|
||||
class="map-card__img"
|
||||
/>
|
||||
<svg v-else class="map-card__placeholder" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" /><line x1="9" y1="3" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3>
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)">
|
||||
{{ map.map_type }}
|
||||
</span>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="map-card__body">
|
||||
<div class="map-card__row">
|
||||
<span class="map-card__name">{{ map.display_name }}</span>
|
||||
<Badge :tone="mapTypeTone(map.map_type)" size="md">{{ map.map_type }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-neutral-500">
|
||||
<div class="map-card__meta">
|
||||
<span>{{ formatSize(map.file_size_bytes) }}</span>
|
||||
<span v-if="map.world_size">{{ map.world_size }}m</span>
|
||||
<span v-if="map.seed">Seed: {{ map.seed }}</span>
|
||||
<span v-if="map.seed" class="mono">Seed: {{ map.seed }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button
|
||||
<div class="map-card__foot">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Delete map"
|
||||
@click="deleteMap(map)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete map"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.maps {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.page__count {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.hidden-input { display: none; }
|
||||
|
||||
/* Spin utility */
|
||||
.spin { animation: spin 0.7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Maps grid */
|
||||
.maps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Map card */
|
||||
.map-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.map-card:hover {
|
||||
box-shadow: var(--ring-default), 0 0 0 1px var(--border-default);
|
||||
}
|
||||
.map-card__thumb {
|
||||
height: 128px;
|
||||
background: var(--surface-inset);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-card__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.map-card__placeholder { color: var(--text-muted); }
|
||||
.map-card__body { padding: 12px 14px; }
|
||||
.map-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.map-card__name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.map-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.map-card__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.maps-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,17 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Module } from '@/types'
|
||||
import { ShoppingCart, Package, Search, Filter, X, Check, Download, AlertCircle } from 'lucide-vue-next'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
@@ -22,17 +31,22 @@ const isPurchasing = ref(false)
|
||||
const purchaseError = ref('')
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'All Modules' },
|
||||
{ value: 'all', label: 'All modules' },
|
||||
{ value: 'loot', label: 'Loot' },
|
||||
{ value: 'events', label: 'Events' },
|
||||
{ value: 'economy', label: 'Economy' },
|
||||
{ value: 'kits', label: 'Kits' },
|
||||
{ value: 'admin', label: 'Admin Tools' },
|
||||
{ value: 'admin', label: 'Admin tools' },
|
||||
{ value: 'pvp', label: 'PVP' },
|
||||
{ value: 'pve', label: 'PVE' },
|
||||
{ value: 'building', label: 'Building' },
|
||||
]
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{ value: 'catalog', label: 'Catalog', icon: 'package' },
|
||||
{ value: 'my-modules', label: `My modules (${myModules.value.length})`, icon: 'download' },
|
||||
])
|
||||
|
||||
const filteredModules = computed(() => {
|
||||
let result = activeTab.value === 'catalog' ? modules.value : myModules.value
|
||||
|
||||
@@ -51,6 +65,21 @@ const filteredModules = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
type BadgeTone = 'info' | 'accent' | 'warn' | 'online' | 'neutral' | 'offline'
|
||||
function categoryTone(category: string): BadgeTone {
|
||||
const map: Record<string, BadgeTone> = {
|
||||
loot: 'warn',
|
||||
events: 'accent',
|
||||
economy: 'online',
|
||||
kits: 'info',
|
||||
admin: 'accent',
|
||||
pvp: 'offline',
|
||||
pve: 'info',
|
||||
building: 'warn',
|
||||
}
|
||||
return map[category] ?? 'neutral'
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -94,10 +123,8 @@ async function confirmPurchase() {
|
||||
})
|
||||
|
||||
if (response.payment_url) {
|
||||
// Redirect to external payment provider
|
||||
window.location.href = response.payment_url
|
||||
} else if (response.success) {
|
||||
// Instant purchase confirmed
|
||||
showPurchaseModal.value = false
|
||||
selectedModule.value = null
|
||||
await loadCatalog()
|
||||
@@ -131,20 +158,6 @@ function closeModals() {
|
||||
purchaseError.value = ''
|
||||
}
|
||||
|
||||
function categoryBadgeClass(category: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
loot: 'bg-yellow-500/15 text-yellow-400',
|
||||
events: 'bg-purple-500/15 text-purple-400',
|
||||
economy: 'bg-green-500/15 text-green-400',
|
||||
kits: 'bg-blue-500/15 text-blue-400',
|
||||
admin: 'bg-oxide-500/15 text-oxide-400',
|
||||
pvp: 'bg-red-500/15 text-red-400',
|
||||
pve: 'bg-indigo-500/15 text-indigo-400',
|
||||
building: 'bg-orange-500/15 text-orange-400',
|
||||
}
|
||||
return colors[category] || 'bg-neutral-700/50 text-neutral-400'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCatalog()
|
||||
loadMyModules()
|
||||
@@ -152,323 +165,371 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<ShoppingCart class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Module Store</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
Extend your server with premium gameplay modules
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
@click="activeTab = 'catalog'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab === 'catalog' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Package class="w-4 h-4" />
|
||||
Catalog
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'my-modules'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab === 'my-modules' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Download class="w-4 h-4" />
|
||||
My Modules ({{ myModules.length }})
|
||||
</span>
|
||||
</button>
|
||||
<div class="ms-page">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Management</div>
|
||||
<h1 class="page__title">Module store</h1>
|
||||
<p class="page__sub">Extend your server with premium gameplay modules.</p>
|
||||
</div>
|
||||
<Tabs v-model="activeTab" :items="tabItems" />
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search modules..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter class="w-4 h-4 text-neutral-500" />
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Filters bar -->
|
||||
<div class="ms-filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search modules…"
|
||||
class="ms-filters__search"
|
||||
/>
|
||||
<Select
|
||||
v-model="selectedCategory"
|
||||
:options="categories"
|
||||
size="md"
|
||||
class="ms-filters__cat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading modules...</div>
|
||||
<div v-if="isLoading" class="ms-loading">
|
||||
<EmptyState icon="loader" title="Loading modules…" />
|
||||
</div>
|
||||
|
||||
<!-- Module grid -->
|
||||
<div v-else-if="filteredModules.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
<div v-else-if="filteredModules.length > 0" class="ms-grid">
|
||||
<article
|
||||
v-for="module in filteredModules"
|
||||
:key="module.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-oxide-500/30 transition-all group"
|
||||
class="ms-card"
|
||||
>
|
||||
<!-- Preview Image -->
|
||||
<div class="relative h-40 bg-neutral-800 overflow-hidden">
|
||||
<!-- Preview image -->
|
||||
<div class="ms-card__img">
|
||||
<img
|
||||
v-if="module.preview_image_url"
|
||||
:src="module.preview_image_url"
|
||||
:alt="module.name"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
class="ms-card__img-el"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Package class="w-12 h-12 text-neutral-700" />
|
||||
<div v-else class="ms-card__img-placeholder">
|
||||
<Icon name="package" :size="36" :stroke-width="1.5" />
|
||||
</div>
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(module.category)">
|
||||
{{ module.category }}
|
||||
</span>
|
||||
<div class="ms-card__img-badges">
|
||||
<Badge :tone="categoryTone(module.category)" size="md">{{ module.category }}</Badge>
|
||||
</div>
|
||||
<div v-if="module.is_purchased" class="absolute top-2 left-2">
|
||||
<span class="text-xs font-medium px-2 py-1 rounded-full bg-green-500/20 text-green-400 flex items-center gap-1">
|
||||
<Check class="w-3 h-3" />
|
||||
Purchased
|
||||
</span>
|
||||
<div v-if="module.is_purchased" class="ms-card__img-owned">
|
||||
<Badge tone="online" icon="check" size="md">Purchased</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 space-y-3">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 class="text-base font-semibold text-neutral-100">{{ module.name }}</h3>
|
||||
<span class="text-lg font-bold text-oxide-400 shrink-0">${{ safeFixed(module.price, 2) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500 line-clamp-2">{{ module.description }}</p>
|
||||
<div class="ms-card__body">
|
||||
<div class="ms-card__header">
|
||||
<span class="ms-card__name">{{ module.name }}</span>
|
||||
<span class="ms-price">${{ safeFixed(module.price, 2) }}</span>
|
||||
</div>
|
||||
<p class="ms-card__desc">{{ module.description }}</p>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
<div class="ms-card__features">
|
||||
<Badge
|
||||
v-for="(feature, idx) in module.features.slice(0, 3)"
|
||||
:key="idx"
|
||||
class="text-xs text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded"
|
||||
>
|
||||
{{ feature }}
|
||||
</span>
|
||||
<span v-if="module.features.length > 3" class="text-xs text-neutral-500">
|
||||
+{{ module.features.length - 3 }} more
|
||||
</span>
|
||||
tone="neutral"
|
||||
>{{ feature }}</Badge>
|
||||
<span v-if="module.features.length > 3" class="ms-card__more">+{{ module.features.length - 3 }} more</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button
|
||||
@click="openDetailModal(module)"
|
||||
class="flex-1 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
<div class="ms-card__actions">
|
||||
<Button variant="secondary" size="sm" :block="false" @click="openDetailModal(module)">Details</Button>
|
||||
<Button
|
||||
v-if="!module.is_purchased"
|
||||
size="sm"
|
||||
@click="initiatePurchase(module)"
|
||||
class="flex-1 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
Purchase
|
||||
</button>
|
||||
<button
|
||||
>Purchase</Button>
|
||||
<Button
|
||||
v-else-if="!module.is_installed"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon="download"
|
||||
@click="installModule(module)"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Download class="w-3.5 h-3.5" />
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
>Install</Button>
|
||||
<Button
|
||||
v-else
|
||||
disabled
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 border border-green-500/20 rounded-lg cursor-default"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Installed
|
||||
</button>
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon="check"
|
||||
:disabled="true"
|
||||
>Installed</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">
|
||||
{{ activeTab === 'catalog' ? 'No modules found' : 'No purchased modules' }}
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ activeTab === 'catalog' ? 'Try adjusting your filters.' : 'Browse the catalog to purchase modules.' }}
|
||||
</p>
|
||||
</div>
|
||||
<Panel v-else>
|
||||
<EmptyState
|
||||
icon="package"
|
||||
:title="activeTab === 'catalog' ? 'No modules found' : 'No purchased modules'"
|
||||
:description="activeTab === 'catalog' ? 'Try adjusting your search or category filter.' : 'Browse the catalog to purchase modules.'"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<!-- ===== Detail modal ===== -->
|
||||
<div
|
||||
v-if="showDetailModal && selectedModule"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="ms-modal-backdrop"
|
||||
@click.self="closeModals"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Modal Header -->
|
||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ selectedModule.name }}</h2>
|
||||
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(selectedModule.category)">
|
||||
{{ selectedModule.category }}
|
||||
</span>
|
||||
<div class="ms-modal ms-modal--wide">
|
||||
<div class="ms-modal__head ms-modal__head--sticky">
|
||||
<div class="ms-modal__head-content">
|
||||
<div class="ms-modal__title-row">
|
||||
<h2 class="ms-modal__title">{{ selectedModule.name }}</h2>
|
||||
<Badge :tone="categoryTone(selectedModule.category)" size="md">{{ selectedModule.category }}</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400">Version {{ selectedModule.version }}</p>
|
||||
<p class="ms-modal__ver">Version {{ selectedModule.version }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="closeModals"
|
||||
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<IconButton icon="x" label="Close" @click="closeModals" />
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="ms-modal__body">
|
||||
<!-- Screenshots -->
|
||||
<div v-if="selectedModule.screenshots.length > 0" class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Screenshots</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-if="selectedModule.screenshots.length > 0" class="ms-detail-section">
|
||||
<div class="ms-detail-section__label">Screenshots</div>
|
||||
<div class="ms-screenshots">
|
||||
<img
|
||||
v-for="(screenshot, idx) in selectedModule.screenshots"
|
||||
:key="idx"
|
||||
:src="screenshot"
|
||||
:alt="`Screenshot ${idx + 1}`"
|
||||
class="w-full h-48 object-cover rounded-lg border border-neutral-800"
|
||||
class="ms-screenshot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Description</h3>
|
||||
<p class="text-sm text-neutral-400 leading-relaxed">{{ selectedModule.description }}</p>
|
||||
<div class="ms-detail-section">
|
||||
<div class="ms-detail-section__label">Description</div>
|
||||
<p class="ms-detail-desc">{{ selectedModule.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Features</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(feature, idx) in selectedModule.features"
|
||||
:key="idx"
|
||||
class="flex items-start gap-2 text-sm text-neutral-400"
|
||||
>
|
||||
<Check class="w-4 h-4 text-oxide-500 shrink-0 mt-0.5" />
|
||||
<div class="ms-detail-section">
|
||||
<div class="ms-detail-section__label">Features</div>
|
||||
<ul class="ms-features">
|
||||
<li v-for="(feature, idx) in selectedModule.features" :key="idx" class="ms-feature">
|
||||
<Icon name="check" :size="14" :stroke-width="2.5" class="ms-feature__icon" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Price and Purchase -->
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-center justify-between">
|
||||
<!-- Price and action -->
|
||||
<div class="ms-detail-cta">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-400 mb-1">One-time purchase</p>
|
||||
<p class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</p>
|
||||
<p class="ms-detail-cta__label">One-time purchase</p>
|
||||
<p class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
v-if="!selectedModule.is_purchased"
|
||||
@click="initiatePurchase(selectedModule)"
|
||||
class="px-6 py-3 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
Purchase Now
|
||||
</button>
|
||||
<button
|
||||
>Purchase now</Button>
|
||||
<Button
|
||||
v-else-if="!selectedModule.is_installed"
|
||||
variant="outline"
|
||||
icon="download"
|
||||
@click="installModule(selectedModule)"
|
||||
class="flex items-center gap-2 px-6 py-3 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
Install
|
||||
</button>
|
||||
<div v-else class="flex items-center gap-2 text-green-400">
|
||||
<Check class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">Installed</span>
|
||||
>Install</Button>
|
||||
<div v-else class="ms-installed">
|
||||
<Icon name="check" :size="18" :stroke-width="2.5" />
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Confirmation Modal -->
|
||||
<!-- ===== Purchase confirmation modal ===== -->
|
||||
<div
|
||||
v-if="showPurchaseModal && selectedModule"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="ms-modal-backdrop"
|
||||
@click.self="closeModals"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-md w-full">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-800 px-6 py-4">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Confirm Purchase</h2>
|
||||
<div class="ms-modal">
|
||||
<div class="ms-modal__head">
|
||||
<h2 class="ms-modal__title">Confirm purchase</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeModals" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-400">Module</span>
|
||||
<span class="text-sm font-medium text-neutral-100">{{ selectedModule.name }}</span>
|
||||
<div class="ms-modal__body">
|
||||
<div class="ms-purchase-summary">
|
||||
<div class="ms-purchase-row">
|
||||
<span class="ms-purchase-row__label">Module</span>
|
||||
<span class="ms-purchase-row__value">{{ selectedModule.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-400">License</span>
|
||||
<span class="text-sm font-medium text-neutral-100">{{ auth.license?.license_key }}</span>
|
||||
<div class="ms-purchase-row">
|
||||
<span class="ms-purchase-row__label">License</span>
|
||||
<span class="ms-purchase-row__value ms-mono">{{ auth.license?.license_key }}</span>
|
||||
</div>
|
||||
<div class="border-t border-neutral-700 pt-2 mt-2 flex items-center justify-between">
|
||||
<span class="text-base font-medium text-neutral-300">Total</span>
|
||||
<span class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</span>
|
||||
<div class="ms-purchase-divider" />
|
||||
<div class="ms-purchase-row ms-purchase-row--total">
|
||||
<span class="ms-purchase-row__label">Total</span>
|
||||
<span class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="purchaseError" class="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
||||
<AlertCircle class="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-red-400">{{ purchaseError }}</p>
|
||||
</div>
|
||||
<Alert v-if="purchaseError" tone="danger">{{ purchaseError }}</Alert>
|
||||
|
||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
||||
<p class="ms-purchase-terms">
|
||||
By confirming this purchase, you agree to the module license terms. This purchase is non-refundable once the module is installed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeModals"
|
||||
:disabled="isPurchasing"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmPurchase"
|
||||
:disabled="isPurchasing"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ isPurchasing ? 'Processing...' : 'Confirm Purchase' }}
|
||||
</button>
|
||||
<div class="ms-modal__foot">
|
||||
<Button variant="secondary" :disabled="isPurchasing" @click="closeModals">Cancel</Button>
|
||||
<Button :loading="isPurchasing" @click="confirmPurchase">Confirm purchase</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ms-page { max-width: 1320px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.page__head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 5px;
|
||||
}
|
||||
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||
|
||||
/* Filters */
|
||||
.ms-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.ms-filters__search { flex: 1; min-width: 200px; max-width: 380px; }
|
||||
.ms-filters__cat { min-width: 160px; }
|
||||
|
||||
.ms-loading { padding: 20px 0; }
|
||||
|
||||
/* Module grid */
|
||||
.ms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
|
||||
/* Module card */
|
||||
.ms-card {
|
||||
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default); overflow: hidden; display: flex; flex-direction: column;
|
||||
transition: box-shadow var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.ms-card:hover { box-shadow: inset 0 0 0 1px var(--accent-border), var(--ring-default); }
|
||||
|
||||
.ms-card__img {
|
||||
position: relative; height: 160px; overflow: hidden;
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.ms-card__img-el {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
transition: transform 300ms var(--ease-standard);
|
||||
}
|
||||
.ms-card:hover .ms-card__img-el { transform: scale(1.04); }
|
||||
.ms-card__img-placeholder {
|
||||
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ms-card__img-badges { position: absolute; top: 8px; right: 8px; }
|
||||
.ms-card__img-owned { position: absolute; top: 8px; left: 8px; }
|
||||
|
||||
.ms-card__body { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||
.ms-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.ms-card__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
||||
.ms-card__desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ms-card__features { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.ms-card__more { font-size: var(--text-xs); color: var(--text-muted); }
|
||||
.ms-card__actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
|
||||
|
||||
/* Price — mono + tabular */
|
||||
.ms-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; font-size: var(--text-base); color: var(--accent-text); }
|
||||
.ms-price--lg { font-size: var(--text-2xl); }
|
||||
.ms-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Modal */
|
||||
.ms-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
|
||||
}
|
||||
.ms-modal {
|
||||
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 480px; width: 100%;
|
||||
display: flex; flex-direction: column; max-height: 90vh;
|
||||
}
|
||||
.ms-modal--wide { max-width: 760px; }
|
||||
|
||||
.ms-modal__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); flex: none;
|
||||
}
|
||||
.ms-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
|
||||
.ms-modal__head-content { flex: 1; min-width: 0; }
|
||||
.ms-modal__title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ms-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||
.ms-modal__ver { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
.ms-modal__body { padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
.ms-modal__foot {
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex: none;
|
||||
}
|
||||
|
||||
/* Detail modal content */
|
||||
.ms-detail-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ms-detail-section__label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.ms-detail-desc { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
|
||||
.ms-screenshots { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.ms-screenshot { width: 100%; height: 180px; object-fit: cover; border-radius: var(--radius-md); box-shadow: var(--ring-default); }
|
||||
|
||||
.ms-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ms-feature { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.ms-feature__icon { flex: none; color: var(--accent-text); margin-top: 2px; }
|
||||
|
||||
.ms-detail-cta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||
}
|
||||
.ms-detail-cta__label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
||||
|
||||
.ms-installed {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: var(--text-sm); font-weight: 500; color: var(--status-online);
|
||||
}
|
||||
|
||||
/* Purchase summary */
|
||||
.ms-purchase-summary {
|
||||
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.ms-purchase-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.ms-purchase-row--total { margin-top: 2px; }
|
||||
.ms-purchase-row__label { font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.ms-purchase-row__value { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.ms-purchase-divider { height: 1px; background: var(--border-subtle); }
|
||||
|
||||
.ms-purchase-terms { font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.ms-grid { grid-template-columns: 1fr; }
|
||||
.ms-filters__search { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
@@ -60,6 +64,10 @@ const loadRetentionData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
|
||||
|
||||
@@ -81,13 +89,22 @@ const renderCharts = () => {
|
||||
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
|
||||
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
|
||||
|
||||
const accent = cssVar('--accent') || '#CE422B'
|
||||
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||
const axisLine = cssVar('--border-default') || '#404040'
|
||||
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
|
||||
retentionChartInstance.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||
formatter: (params: any) => {
|
||||
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
|
||||
params.forEach((param: any) => {
|
||||
@@ -98,7 +115,7 @@ const renderCharts = () => {
|
||||
},
|
||||
legend: {
|
||||
data: ['24h Return', '48h Return', '72h Return'],
|
||||
textStyle: { color: '#a3a3a3' },
|
||||
textStyle: { color: labelColor, fontFamily: mono },
|
||||
top: 0
|
||||
},
|
||||
grid: {
|
||||
@@ -111,16 +128,18 @@ const renderCharts = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: wipeLabels,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Retention %',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: {
|
||||
color: '#808080',
|
||||
color: labelColor,
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => `${value}%`
|
||||
},
|
||||
max: 100
|
||||
@@ -131,8 +150,8 @@ const renderCharts = () => {
|
||||
type: 'line',
|
||||
data: retention24h,
|
||||
smooth: true,
|
||||
lineStyle: { color: '#CE422B', width: 3 },
|
||||
itemStyle: { color: '#CE422B' },
|
||||
lineStyle: { color: accent, width: 3 },
|
||||
itemStyle: { color: accent },
|
||||
symbolSize: 8
|
||||
},
|
||||
{
|
||||
@@ -187,117 +206,91 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="retention-view">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Player Retention</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="downloadCSV"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
<div class="retention-view__header">
|
||||
<h1 class="retention-view__title">Player retention</h1>
|
||||
<div class="retention-view__controls">
|
||||
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||
Export CSV
|
||||
</button>
|
||||
<div class="flex items-center gap-2 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2">
|
||||
<label class="text-sm text-neutral-400">Wipes:</label>
|
||||
<select
|
||||
v-model.number="wipeCount"
|
||||
class="bg-transparent text-neutral-100 text-sm focus:outline-none"
|
||||
>
|
||||
<option :value="3">Last 3</option>
|
||||
<option :value="6">Last 6</option>
|
||||
<option :value="10">Last 10</option>
|
||||
<option :value="20">Last 20</option>
|
||||
</select>
|
||||
</div>
|
||||
</Button>
|
||||
<Select
|
||||
:options="[
|
||||
{ value: '3', label: 'Last 3 wipes' },
|
||||
{ value: '6', label: 'Last 6 wipes' },
|
||||
{ value: '10', label: 'Last 10 wipes' },
|
||||
{ value: '20', label: 'Last 20 wipes' }
|
||||
]"
|
||||
:model-value="String(wipeCount)"
|
||||
size="sm"
|
||||
@update:model-value="wipeCount = Number($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading retention data...</div>
|
||||
<div v-if="loading" class="retention-view__loading">
|
||||
<span class="retention-view__loading-text">Loading retention data...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Users class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Unique Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.unique_players }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Avg Session</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">
|
||||
{{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Duration</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">New Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.new_players }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Returning</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.returning_players }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||
</div>
|
||||
<div class="retention-view__stats">
|
||||
<StatCard
|
||||
label="Unique players"
|
||||
:value="retentionData.summary.unique_players"
|
||||
icon="users"
|
||||
note="Last 30 days"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg session"
|
||||
:value="safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0)"
|
||||
unit="m"
|
||||
icon="clock"
|
||||
note="Duration"
|
||||
/>
|
||||
<StatCard
|
||||
label="New players"
|
||||
:value="retentionData.summary.new_players"
|
||||
icon="trending-up"
|
||||
note="Last 30 days"
|
||||
/>
|
||||
<StatCard
|
||||
label="Returning"
|
||||
:value="retentionData.summary.returning_players"
|
||||
icon="bar-chart-3"
|
||||
note="Last 30 days"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Retention curve chart -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Retention Curve (Post-Wipe Return Rates)
|
||||
</h2>
|
||||
<div ref="retentionChart" class="h-96"></div>
|
||||
<div class="mt-4 text-xs text-neutral-500">
|
||||
<p>
|
||||
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and
|
||||
returned within 24h/48h/72h after the wipe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Panel title="Retention curve (post-wipe return rates)">
|
||||
<div ref="retentionChart" class="retention-view__chart-area"></div>
|
||||
<p class="retention-view__chart-note">
|
||||
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and returned within 24h/48h/72h after the wipe.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<!-- Wipe details table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||
Wipe Details
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<Panel title="Wipe details" :flush-body="true">
|
||||
<div class="retention-view__table-wrap">
|
||||
<table class="retention-view__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-2 px-3 text-neutral-400 font-medium">Wipe Date</th>
|
||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">Pre-Wipe Players</th>
|
||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">24h Return</th>
|
||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">48h Return</th>
|
||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">72h Return</th>
|
||||
<tr class="retention-view__thead-row">
|
||||
<th class="retention-view__th retention-view__th--left">Wipe date</th>
|
||||
<th class="retention-view__th retention-view__th--right">Pre-wipe players</th>
|
||||
<th class="retention-view__th retention-view__th--right">24h return</th>
|
||||
<th class="retention-view__th retention-view__th--right">48h return</th>
|
||||
<th class="retention-view__th retention-view__th--right">72h return</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="wipe in retentionData.wipe_metrics"
|
||||
:key="wipe.wipe_id"
|
||||
class="border-b border-neutral-800 hover:bg-neutral-800/50 transition-colors"
|
||||
class="retention-view__row"
|
||||
>
|
||||
<td class="py-3 px-3 text-neutral-300">
|
||||
<td class="retention-view__td retention-view__td--date">
|
||||
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -306,38 +299,165 @@ onMounted(() => {
|
||||
minute: '2-digit'
|
||||
}) }}
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right text-neutral-300">
|
||||
<td class="retention-view__td retention-view__td--num">
|
||||
{{ wipe.total_players_before_wipe }}
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
|
||||
<td class="retention-view__td retention-view__td--num">
|
||||
<span class="retention-view__count">{{ wipe.returned_24h }}</span>
|
||||
<span class="retention-view__pct">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
|
||||
<td class="retention-view__td retention-view__td--num">
|
||||
<span class="retention-view__count">{{ wipe.returned_48h }}</span>
|
||||
<span class="retention-view__pct">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
|
||||
<td class="retention-view__td retention-view__td--num">
|
||||
<span class="retention-view__count">{{ wipe.returned_72h }}</span>
|
||||
<span class="retention-view__pct">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
|
||||
>
|
||||
<Users class="w-12 h-12 text-neutral-700 mx-auto mb-4" />
|
||||
<p class="text-neutral-500 mb-2">No retention data available</p>
|
||||
<p class="text-sm text-neutral-600">
|
||||
Player retention metrics will appear here after wipes are tracked and players join/leave.
|
||||
</p>
|
||||
</div>
|
||||
<Panel v-else>
|
||||
<EmptyState
|
||||
icon="users"
|
||||
title="No retention data available"
|
||||
description="Player retention metrics will appear here after wipes are tracked and players join/leave."
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.retention-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.retention-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.retention-view__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retention-view__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.retention-view__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.retention-view__loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.retention-view__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.retention-view__stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.retention-view__chart-area {
|
||||
height: 384px;
|
||||
}
|
||||
|
||||
.retention-view__chart-note {
|
||||
margin-top: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retention-view__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.retention-view__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.retention-view__thead-row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.retention-view__th {
|
||||
padding: 10px 12px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.retention-view__th--left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.retention-view__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.retention-view__row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.retention-view__row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.retention-view__td {
|
||||
padding: 12px 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.retention-view__td--date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.retention-view__td--num {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.retention-view__count {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retention-view__pct {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const server = useServerStore()
|
||||
const api = useApi()
|
||||
@@ -50,6 +57,12 @@ const filteredPlayers = computed(() => {
|
||||
|
||||
const onlineCount = computed(() => players.value.filter(p => p.is_online).length)
|
||||
|
||||
const statusTabItems = computed(() => [
|
||||
{ value: 'all', label: 'All', count: players.value.length },
|
||||
{ value: 'online', label: 'Online', count: players.value.filter(p => p.is_online).length },
|
||||
{ value: 'offline', label: 'Offline', count: players.value.filter(p => !p.is_online).length },
|
||||
])
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
@@ -58,7 +71,7 @@ function formatPlaytime(seconds: number): string {
|
||||
}
|
||||
|
||||
function formatConnectedTime(iso: string | null): string {
|
||||
if (!iso) return '\u2014'
|
||||
if (!iso) return '—'
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000)
|
||||
const h = Math.floor(m / 60)
|
||||
@@ -66,6 +79,18 @@ function formatConnectedTime(iso: string | null): string {
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
function playerStatusTone(player: Player): 'online' | 'offline' | 'warn' {
|
||||
if (player.is_banned) return 'warn'
|
||||
if (player.is_online) return 'online'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
function playerStatusLabel(player: Player): string {
|
||||
if (player.is_banned) return 'Banned'
|
||||
if (player.is_online) return 'Online'
|
||||
return 'Offline'
|
||||
}
|
||||
|
||||
async function fetchPlayers() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -106,132 +131,197 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="w-5 h-5 text-oxide-500" />
|
||||
<div class="pv">
|
||||
<!-- Page head -->
|
||||
<div class="pv__head">
|
||||
<div class="pv__head-id">
|
||||
<div class="pv__head-chip">
|
||||
<Icon name="users" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Player Management</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
{{ onlineCount }} online / {{ players.length }} total
|
||||
</p>
|
||||
<div class="t-eyebrow">Player management</div>
|
||||
<h1 class="pv__title">Players</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="fetchPlayers"
|
||||
:disabled="isLoading"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
<div class="pv__head-actions">
|
||||
<div class="pv__stat-pill">
|
||||
<span class="pv__stat-num">{{ onlineCount }}</span>
|
||||
<span class="pv__stat-sep">/</span>
|
||||
<span class="pv__stat-total">{{ players.length }}</span>
|
||||
<Badge tone="online" :dot="true" :pulse="onlineCount > 0">Online</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="fetchPlayers"
|
||||
>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search by name or Steam ID..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['all', 'online', 'offline'] as const)"
|
||||
:key="opt"
|
||||
@click="filterStatus = opt"
|
||||
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
||||
:class="filterStatus === opt
|
||||
? 'bg-oxide-500/15 text-oxide-400'
|
||||
: 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="pv__filters">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search by name or Steam ID…"
|
||||
size="sm"
|
||||
:mono="false"
|
||||
style="max-width: 320px;"
|
||||
/>
|
||||
<Tabs v-model="filterStatus" :items="statusTabItems" />
|
||||
</div>
|
||||
|
||||
<!-- Player table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true">
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
v-if="filteredPlayers.length === 0 && !isLoading"
|
||||
icon="users"
|
||||
:title="searchQuery ? 'No players found' : 'No players'"
|
||||
:description="searchQuery
|
||||
? `No players matching "${searchQuery}".`
|
||||
: 'No players found. Server may be offline or API not connected.'"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="isLoading && filteredPlayers.length === 0" class="pv__loading">
|
||||
<Icon name="loader" :size="20" class="pv__spin" />
|
||||
<span>Loading players…</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table v-else class="pv__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Steam ID</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Session</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Playtime</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Ping</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Steam ID</th>
|
||||
<th>Status</th>
|
||||
<th>Session</th>
|
||||
<th>Playtime</th>
|
||||
<th>Ping</th>
|
||||
<th class="pv__th-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="filteredPlayers.length === 0">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading players...</template>
|
||||
<template v-else-if="searchQuery">No players matching "{{ searchQuery }}"</template>
|
||||
<template v-else>No players found. Server may be offline or API not connected.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="player in filteredPlayers"
|
||||
:key="player.steam_id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-neutral-100">{{ player.display_name }}</span>
|
||||
<Shield v-if="player.is_admin" class="w-3.5 h-3.5 text-oxide-400" title="Admin" />
|
||||
<td>
|
||||
<div class="pv__player-name">
|
||||
<span class="pv__name">{{ player.display_name }}</span>
|
||||
<Badge v-if="player.is_admin" tone="accent" size="md">Admin</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ player.steam_id }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="player.is_online
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: player.is_banned
|
||||
? 'bg-red-500/10 text-red-400'
|
||||
: 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="player.is_online ? 'bg-green-500' : player.is_banned ? 'bg-red-500' : 'bg-neutral-500'"
|
||||
/>
|
||||
{{ player.is_banned ? 'Banned' : player.is_online ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
<td class="pv__mono">{{ player.steam_id }}</td>
|
||||
<td>
|
||||
<Badge
|
||||
:tone="playerStatusTone(player)"
|
||||
:dot="true"
|
||||
:pulse="player.is_online && !player.is_banned"
|
||||
>{{ playerStatusLabel(player) }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||
{{ player.is_online ? formatConnectedTime(player.connected_at) : '\u2014' }}
|
||||
<td class="pv__secondary">
|
||||
{{ player.is_online ? formatConnectedTime(player.connected_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatPlaytime(player.playtime_seconds) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '\u2014' }}
|
||||
<td class="pv__secondary pv__mono">{{ formatPlaytime(player.playtime_seconds) }}</td>
|
||||
<td class="pv__secondary pv__mono">
|
||||
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1" v-if="player.is_online">
|
||||
<button
|
||||
<td class="pv__td-right">
|
||||
<div v-if="player.is_online" class="pv__row-actions">
|
||||
<IconButton
|
||||
icon="log-out"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Kick"
|
||||
@click="kickPlayer(player.steam_id, player.display_name)"
|
||||
class="p-1.5 text-neutral-500 hover:text-yellow-400 rounded transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon="ban"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Ban"
|
||||
@click="banPlayer(player.steam_id, player.display_name)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<Ban class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.pv__head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.pv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.pv__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.pv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.pv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Stat pill */
|
||||
.pv__stat-pill {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pv__stat-num { font-weight: 700; color: var(--status-online); }
|
||||
.pv__stat-sep { color: var(--text-muted); }
|
||||
.pv__stat-total { color: var(--text-tertiary); }
|
||||
|
||||
/* Filters */
|
||||
.pv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* Loading */
|
||||
.pv__loading {
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||
}
|
||||
@keyframes pv-spin { to { transform: rotate(360deg); } }
|
||||
.pv__spin { animation: pv-spin 0.7s linear infinite; }
|
||||
|
||||
/* Table */
|
||||
.pv__table { width: 100%; border-collapse: collapse; }
|
||||
.pv__table th {
|
||||
padding: 10px 14px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.pv__table td {
|
||||
padding: 10px 14px; font-size: var(--text-sm);
|
||||
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.pv__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.pv__table tbody tr:hover td { background: var(--surface-hover); }
|
||||
|
||||
/* Col helpers */
|
||||
.pv__th-right { text-align: right; }
|
||||
.pv__td-right { text-align: right; }
|
||||
.pv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
|
||||
.pv__secondary { color: var(--text-secondary) !important; }
|
||||
|
||||
/* Player name cell */
|
||||
.pv__player-name { display: flex; align-items: center; gap: 8px; }
|
||||
.pv__name { font-weight: 500; }
|
||||
|
||||
/* Row actions */
|
||||
.pv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,15 @@ import { usePluginStore } from '@/stores/plugins'
|
||||
import type { UmodPlugin } from '@/stores/plugins'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { PluginEntry } from '@/types'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const toast = useToastStore()
|
||||
@@ -35,6 +43,12 @@ const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
||||
|
||||
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
||||
|
||||
const tabItems = [
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'browse', label: 'Browse uMod' },
|
||||
{ value: 'upload', label: 'Upload custom' },
|
||||
]
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'umod': return 'uMod'
|
||||
@@ -44,11 +58,11 @@ function sourceLabel(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function sourceBadgeClass(source: string): string {
|
||||
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
|
||||
switch (source) {
|
||||
case 'umod': return 'bg-green-500/10 text-green-400'
|
||||
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
case 'umod': return 'online'
|
||||
case 'corrosion_module': return 'accent'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,274 +182,249 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Puzzle class="w-5 h-5 text-oxide-500" />
|
||||
<div class="plv">
|
||||
<!-- Page head -->
|
||||
<div class="plv__head">
|
||||
<div class="plv__head-id">
|
||||
<div class="plv__head-chip">
|
||||
<Icon name="puzzle" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
|
||||
</p>
|
||||
<div class="t-eyebrow">Plugin management</div>
|
||||
<h1 class="plv__title">Plugins</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="pluginStore.fetchPlugins()"
|
||||
:disabled="pluginStore.isLoading"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + Search -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
@click="tab = 'installed'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Installed
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'browse'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'browse' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Browse uMod
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'upload'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Upload Custom
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search installed plugins..."
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="browseQuery"
|
||||
type="text"
|
||||
placeholder="Search uMod plugins..."
|
||||
@input="scheduleBrowseSearch"
|
||||
@keydown.enter="handleBrowseSearch(1)"
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
<div class="plv__head-actions">
|
||||
<div class="plv__stat-pill">
|
||||
<span class="plv__stat-num">{{ loadedCount }}</span>
|
||||
<span class="plv__stat-sep">/</span>
|
||||
<span class="plv__stat-total">{{ pluginStore.plugins.length }}</span>
|
||||
<span class="plv__stat-label">loaded</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="pluginStore.isLoading"
|
||||
:disabled="pluginStore.isLoading"
|
||||
@click="pluginStore.fetchPlugins()"
|
||||
>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Plugins -->
|
||||
<div v-if="tab === 'installed'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- Tab bar + search -->
|
||||
<div class="plv__toolbar">
|
||||
<Tabs v-model="tab" :items="tabItems" />
|
||||
<Input
|
||||
v-if="tab === 'installed'"
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search installed plugins…"
|
||||
size="sm"
|
||||
style="max-width: 280px;"
|
||||
/>
|
||||
<Input
|
||||
v-if="tab === 'browse'"
|
||||
v-model="browseQuery"
|
||||
icon="search"
|
||||
placeholder="Search uMod plugins…"
|
||||
size="sm"
|
||||
style="max-width: 280px;"
|
||||
@input="scheduleBrowseSearch"
|
||||
@keydown.enter="handleBrowseSearch(1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ===== INSTALLED TAB ===== -->
|
||||
<Panel v-if="tab === 'installed'" :flush-body="true">
|
||||
<EmptyState
|
||||
v-if="filteredPlugins.length === 0 && !pluginStore.isLoading"
|
||||
icon="puzzle"
|
||||
title="No plugins installed"
|
||||
:description="searchQuery ? `No plugins matching "${searchQuery}".` : 'Install plugins from the Browse uMod tab or upload a custom .cs file.'"
|
||||
/>
|
||||
<div v-else-if="pluginStore.isLoading && filteredPlugins.length === 0" class="plv__loading">
|
||||
<Icon name="loader" :size="20" class="plv__spin" />
|
||||
<span>Loading plugins…</span>
|
||||
</div>
|
||||
<table v-else class="plv__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Plugin</th>
|
||||
<th>Version</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Wipe behavior</th>
|
||||
<th class="plv__th-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="filteredPlugins.length === 0">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="pluginStore.isLoading">Loading plugins...</template>
|
||||
<template v-else>No plugins installed yet.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="plugin in filteredPlugins"
|
||||
:key="plugin.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ plugin.plugin_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)">
|
||||
{{ sourceLabel(plugin.source) }}
|
||||
</span>
|
||||
<td class="plv__plugin-name">{{ plugin.plugin_name }}</td>
|
||||
<td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
|
||||
<td>
|
||||
<Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium"
|
||||
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="plugin.is_loaded ? 'bg-green-500' : 'bg-neutral-600'" />
|
||||
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
|
||||
</span>
|
||||
<td>
|
||||
<Badge
|
||||
:tone="plugin.is_loaded ? 'online' : 'offline'"
|
||||
:dot="true"
|
||||
:pulse="plugin.is_loaded"
|
||||
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-neutral-500">
|
||||
<td class="plv__secondary plv__wipe-cell">
|
||||
<template v-if="plugin.never_wipe">Never wipe</template>
|
||||
<template v-else>
|
||||
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
<td class="plv__td-right">
|
||||
<div class="plv__row-actions">
|
||||
<IconButton
|
||||
:icon="plugin.is_loaded ? 'power' : 'play'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="plugin.is_loaded ? 'Unload' : 'Load'"
|
||||
@click="handleToggleLoad(plugin)"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
|
||||
:title="plugin.is_loaded ? 'Unload' : 'Load'"
|
||||
>
|
||||
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Uninstall"
|
||||
@click="handleUninstall(plugin)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Uninstall"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Browse uMod -->
|
||||
<!-- ===== BROWSE UMOD TAB ===== -->
|
||||
<div v-if="tab === 'browse'">
|
||||
<!-- Empty state: no search yet -->
|
||||
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
||||
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
||||
</div>
|
||||
<!-- No search yet -->
|
||||
<Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title="Search uMod"
|
||||
description="Type a plugin name above to search the uMod plugin directory."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
||||
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
||||
</div>
|
||||
<Panel v-else-if="pluginStore.isBrowseLoading">
|
||||
<div class="plv__loading">
|
||||
<Icon name="loader" :size="20" class="plv__spin" />
|
||||
<span>Searching uMod…</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
||||
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
||||
</div>
|
||||
<Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
|
||||
<EmptyState
|
||||
icon="download"
|
||||
title="No plugins found"
|
||||
:description="`No uMod plugins matched "${browseQuery}". Try a different search term.`"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
||||
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="browsePrev"
|
||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
@click="browseNext"
|
||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<Panel v-else :flush-body="true">
|
||||
<template #actions>
|
||||
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
|
||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
|
||||
· page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron-left"
|
||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||
@click="browsePrev"
|
||||
>Prev</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-right="chevron-right"
|
||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||
@click="browseNext"
|
||||
>Next</Button>
|
||||
</template>
|
||||
<table class="plv__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
||||
<tr>
|
||||
<th>Plugin</th>
|
||||
<th>Author</th>
|
||||
<th>Version</th>
|
||||
<th>Downloads</th>
|
||||
<th class="plv__th-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="result in browsePlugins"
|
||||
:key="result.name"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
|
||||
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
||||
<td>
|
||||
<div class="plv__browse-name">{{ result.title }}</div>
|
||||
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
@click="installFromBrowse(result)"
|
||||
<td class="plv__secondary">{{ result.author ?? '—' }}</td>
|
||||
<td class="plv__mono">{{ result.latest_release_version_formatted ?? '—' }}</td>
|
||||
<td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '—' }}</td>
|
||||
<td class="plv__td-right">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="isAlreadyInstalled(result.name) ? 'secondary' : 'primary'"
|
||||
:icon="installing === result.name ? 'loader' : 'download'"
|
||||
:loading="installing === result.name"
|
||||
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
||||
:class="isAlreadyInstalled(result.name)
|
||||
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
||||
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
||||
>
|
||||
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Download v-else class="w-3.5 h-3.5" />
|
||||
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
@click="installFromBrowse(result)"
|
||||
>{{ isAlreadyInstalled(result.name) ? 'Installed' : 'Install' }}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Bottom pagination -->
|
||||
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
|
||||
<p class="text-xs text-neutral-500">
|
||||
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
|
||||
<span class="plv__browse-meta">
|
||||
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="browsePrev"
|
||||
</span>
|
||||
<div class="plv__browse-pag">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="chevron-left"
|
||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
@click="browseNext"
|
||||
@click="browsePrev"
|
||||
>Previous</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon-right="chevron-right"
|
||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
@click="browseNext"
|
||||
>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Upload Custom Plugin -->
|
||||
<div v-if="tab === 'upload'" class="space-y-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
|
||||
<p class="text-sm text-neutral-500 mb-6">
|
||||
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
|
||||
The file will be pushed to your server via the companion agent.
|
||||
</p>
|
||||
<!-- ===== UPLOAD CUSTOM TAB ===== -->
|
||||
<div v-if="tab === 'upload'" class="plv__upload-wrap">
|
||||
<Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
|
||||
:class="isDragOver
|
||||
? 'border-oxide-500 bg-oxide-500/5'
|
||||
: uploadFile
|
||||
? 'border-green-600 bg-green-900/10'
|
||||
: 'border-neutral-700 hover:border-neutral-600'"
|
||||
class="plv__dropzone"
|
||||
:class="{
|
||||
'plv__dropzone--active': isDragOver,
|
||||
'plv__dropzone--ready': !!uploadFile && !isDragOver,
|
||||
}"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@@ -445,65 +434,174 @@ onMounted(() => {
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept=".cs"
|
||||
class="hidden"
|
||||
class="plv__file-hidden"
|
||||
@change="handleFilePick"
|
||||
/>
|
||||
|
||||
<!-- No file selected -->
|
||||
<!-- No file -->
|
||||
<template v-if="!uploadFile">
|
||||
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
|
||||
<div class="plv__drop-icon">
|
||||
<Icon name="upload" :size="22" :stroke-width="1.75" />
|
||||
</div>
|
||||
<p class="plv__drop-label">Drop your .cs file here</p>
|
||||
<p class="plv__drop-hint">or click to browse</p>
|
||||
</template>
|
||||
|
||||
<!-- File selected -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
|
||||
<div class="plv__file-row">
|
||||
<div class="plv__file-icon">
|
||||
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
|
||||
</div>
|
||||
<button
|
||||
<div class="plv__file-info">
|
||||
<div class="plv__file-name">{{ uploadFile.name }}</div>
|
||||
<div class="plv__file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</div>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="Remove"
|
||||
@click.stop="clearUpload"
|
||||
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
@click="handleUpload"
|
||||
<div class="plv__upload-actions">
|
||||
<Button
|
||||
icon="upload"
|
||||
:loading="isUploading"
|
||||
:disabled="!uploadFile || isUploading"
|
||||
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleUpload"
|
||||
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
|
||||
<Button
|
||||
v-if="uploadFile"
|
||||
variant="ghost"
|
||||
@click="clearUpload"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Info card -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
||||
<span class="font-medium text-neutral-400">Note:</span>
|
||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||
</p>
|
||||
</div>
|
||||
<Alert tone="info">
|
||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Page head */
|
||||
.plv__head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.plv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.plv__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.plv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.plv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Stat pill */
|
||||
.plv__stat-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plv__stat-num { font-weight: 700; color: var(--accent-text); }
|
||||
.plv__stat-sep { color: var(--text-muted); }
|
||||
.plv__stat-total { color: var(--text-tertiary); }
|
||||
.plv__stat-label { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
/* Toolbar */
|
||||
.plv__toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* Loading */
|
||||
.plv__loading {
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||
}
|
||||
@keyframes plv-spin { to { transform: rotate(360deg); } }
|
||||
.plv__spin { animation: plv-spin 0.7s linear infinite; }
|
||||
|
||||
/* Table */
|
||||
.plv__table { width: 100%; border-collapse: collapse; }
|
||||
.plv__table th {
|
||||
padding: 10px 14px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.plv__table td {
|
||||
padding: 10px 14px; font-size: var(--text-sm);
|
||||
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.plv__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.plv__table tbody tr:hover td { background: var(--surface-hover); }
|
||||
.plv__th-right { text-align: right; }
|
||||
.plv__td-right { text-align: right; }
|
||||
.plv__plugin-name { font-weight: 500; }
|
||||
.plv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
|
||||
.plv__secondary { color: var(--text-secondary) !important; }
|
||||
.plv__wipe-cell { font-size: var(--text-xs) !important; }
|
||||
.plv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
|
||||
|
||||
/* Browse */
|
||||
.plv__browse-meta { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||
.plv__browse-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.plv__browse-desc {
|
||||
font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px;
|
||||
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.plv__browse-foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.plv__browse-pag { display: flex; gap: 8px; }
|
||||
|
||||
/* Upload */
|
||||
.plv__upload-wrap { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.plv__dropzone {
|
||||
border: 2px dashed var(--border-default); border-radius: var(--radius-md);
|
||||
padding: 40px 24px; text-align: center;
|
||||
cursor: pointer; transition: var(--transition-colors);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
}
|
||||
.plv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||
.plv__dropzone--active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.plv__dropzone--ready { border-color: var(--status-online-border); background: var(--status-online-soft); }
|
||||
|
||||
.plv__file-hidden { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
||||
|
||||
.plv__drop-icon {
|
||||
width: 46px; height: 46px; border-radius: var(--radius-lg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--surface-raised-2); color: var(--text-tertiary);
|
||||
box-shadow: var(--ring-default); margin-bottom: 4px;
|
||||
}
|
||||
.plv__drop-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||
.plv__drop-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
.plv__file-row { display: flex; align-items: center; gap: 12px; }
|
||||
.plv__file-icon {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--status-online-soft); color: var(--status-online);
|
||||
box-shadow: inset 0 0 0 1px var(--status-online-border);
|
||||
}
|
||||
.plv__file-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.plv__file-size { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||
|
||||
.plv__upload-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Clock, Plus, Edit, Trash2, Power, Loader2 } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string
|
||||
@@ -12,7 +18,7 @@ interface ScheduledTask {
|
||||
timezone: string
|
||||
is_active: boolean
|
||||
next_run: string | null
|
||||
task_config: Record<string, any>
|
||||
task_config: Record<string, unknown>
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
@@ -31,6 +37,13 @@ const formData = ref({
|
||||
|
||||
const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
|
||||
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ value: 'restart', label: 'Restart' },
|
||||
{ value: 'announcement', label: 'Announcement' },
|
||||
{ value: 'command', label: 'Command' },
|
||||
{ value: 'plugin_reload', label: 'Plugin reload' },
|
||||
]
|
||||
|
||||
async function fetchTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -95,167 +108,295 @@ async function toggleActive(task: ScheduledTask) {
|
||||
await fetchTasks()
|
||||
}
|
||||
|
||||
function taskTypeLabel(type: string): string {
|
||||
return type.replace('_', ' ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Clock class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Scheduled Tasks</h1>
|
||||
<div class="schedules">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Operations</div>
|
||||
<h1 class="page__title">Scheduled tasks</h1>
|
||||
</div>
|
||||
<button
|
||||
@click="openCreateModal"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Task
|
||||
</button>
|
||||
<Button icon="plus" @click="openCreateModal">New task</Button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div v-if="isLoading" class="p-8 flex justify-center">
|
||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
||||
<!-- Tasks table -->
|
||||
<Panel :flush-body="true" title="Tasks">
|
||||
<div v-if="isLoading" class="loading-row">
|
||||
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="p-8 text-center text-neutral-500">
|
||||
No scheduled tasks configured.
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
||||
|
||||
<EmptyState
|
||||
v-else-if="tasks.length === 0"
|
||||
icon="calendar-clock"
|
||||
title="No scheduled tasks"
|
||||
description="Create tasks to automate restarts, announcements, and commands."
|
||||
>
|
||||
<template #action>
|
||||
<Button icon="plus" size="sm" @click="openCreateModal">New task</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Task Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Schedule</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Timezone</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Next Run</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
<th>Task name</th>
|
||||
<th>Type</th>
|
||||
<th>Schedule</th>
|
||||
<th>Timezone</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="task in tasks" :key="task.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ task.task_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ task.task_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-neutral-400">{{ task.cron_expression }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.timezone }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(task.next_run, '—') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="task.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id">
|
||||
<td class="td-primary">{{ task.task_name }}</td>
|
||||
<td class="td-cap">{{ taskTypeLabel(task.task_type) }}</td>
|
||||
<td class="td-mono">{{ task.cron_expression }}</td>
|
||||
<td>{{ task.timezone }}</td>
|
||||
<td class="td-mono">{{ safeDate(task.next_run, '—') }}</td>
|
||||
<td>
|
||||
<Badge :tone="task.is_active ? 'online' : 'neutral'">
|
||||
{{ task.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<IconButton
|
||||
icon="power"
|
||||
size="sm"
|
||||
:label="task.is_active ? 'Pause' : 'Activate'"
|
||||
@click="toggleActive(task)"
|
||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
||||
:title="task.is_active ? 'Pause' : 'Activate'"
|
||||
>
|
||||
<Power class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
size="sm"
|
||||
label="Edit"
|
||||
@click="openEditModal(task)"
|
||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
||||
>
|
||||
<Edit class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Delete"
|
||||
@click="deleteTask(task.id)"
|
||||
class="text-neutral-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<!-- Create / Edit Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
class="modal-backdrop"
|
||||
@click.self="showModal = false"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg w-full max-w-lg">
|
||||
<div class="p-5 border-b border-neutral-800">
|
||||
<h2 class="text-lg font-bold text-neutral-100">{{ editingTask ? 'Edit Task' : 'New Task' }}</h2>
|
||||
<div class="modal">
|
||||
<div class="modal__head">
|
||||
<h2 class="modal__title">{{ editingTask ? 'Edit task' : 'New task' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="showModal = false" />
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Name</label>
|
||||
<input
|
||||
v-model="formData.task_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder="Daily restart"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Type</label>
|
||||
<select
|
||||
v-model="formData.task_type"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
>
|
||||
<option value="restart">Restart</option>
|
||||
<option value="announcement">Announcement</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="plugin_reload">Plugin Reload</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Cron Expression</label>
|
||||
<input
|
||||
v-model="formData.cron_expression"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
<p class="text-xs text-neutral-500 mt-1">Example: "0 0 * * *" = daily at midnight</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Timezone</label>
|
||||
<select
|
||||
v-model="formData.timezone"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
>
|
||||
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Config (JSON)</label>
|
||||
|
||||
<div class="modal__body">
|
||||
<Input
|
||||
v-model="formData.task_name"
|
||||
label="Task name"
|
||||
placeholder="Daily restart"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-model="formData.task_type"
|
||||
label="Task type"
|
||||
:options="TASK_TYPE_OPTIONS"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="formData.cron_expression"
|
||||
label="Cron expression"
|
||||
placeholder="0 0 * * *"
|
||||
:mono="true"
|
||||
hint="Example: 0 0 * * * = daily at midnight"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-model="formData.timezone"
|
||||
label="Timezone"
|
||||
:options="timezones"
|
||||
/>
|
||||
|
||||
<div class="cc-field">
|
||||
<span class="cc-field__label">Task config (JSON)</span>
|
||||
<textarea
|
||||
v-model="formData.task_config"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder='{"message": "Server restarting..."}'
|
||||
class="cc-textarea cc-textarea--mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 border-t border-neutral-800 flex justify-end gap-3">
|
||||
<button
|
||||
@click="showModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveTask"
|
||||
class="px-4 py-2 text-sm font-medium bg-oxide-500 hover:bg-oxide-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingTask ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
|
||||
<div class="modal__foot">
|
||||
<Button variant="secondary" @click="showModal = false">Cancel</Button>
|
||||
<Button @click="saveTask">{{ editingTask ? 'Update' : 'Create' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schedules {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Loading row */
|
||||
.loading-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.td-cap { text-transform: capitalize; }
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.modal__title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal__body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
.cc-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: 0;
|
||||
transition: var(--transition-colors);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.cc-textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,13 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { StoreConfig } from '@/types'
|
||||
import { Store, Save, Loader2, AlertTriangle, DollarSign } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
@@ -22,10 +28,18 @@ const isLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isConfigured = ref(false)
|
||||
|
||||
// DS Input uses defineModel<string>() which emits string | undefined, but
|
||||
// paypal_client_id is string | null in the DB type. Bridge null ↔ undefined
|
||||
// so we never change what gets saved to the API.
|
||||
const paypalClientId = computed({
|
||||
get: () => config.value.paypal_client_id ?? undefined,
|
||||
set: (v: string | undefined) => { config.value.paypal_client_id = v ?? null },
|
||||
})
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'USD', label: 'USD - US Dollar' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'GBP', label: 'GBP - British Pound' },
|
||||
{ value: 'USD', label: 'USD — US Dollar' },
|
||||
{ value: 'EUR', label: 'EUR — Euro' },
|
||||
{ value: 'GBP', label: 'GBP — British Pound' },
|
||||
]
|
||||
|
||||
const showPayPalWarning = computed(() => {
|
||||
@@ -40,7 +54,6 @@ async function fetchConfig() {
|
||||
isConfigured.value = !!data.config.store_name
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
// No config yet, use defaults
|
||||
isConfigured.value = false
|
||||
} else {
|
||||
toast.error('Failed to load store configuration')
|
||||
@@ -51,7 +64,6 @@ async function fetchConfig() {
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
// Validation
|
||||
if (!config.value.store_name.trim()) {
|
||||
toast.error('Store name is required')
|
||||
return
|
||||
@@ -73,7 +85,6 @@ async function saveConfig() {
|
||||
enabled: config.value.enabled,
|
||||
}
|
||||
|
||||
// Only include secret if it was entered
|
||||
if (paypalSecret.value.trim()) {
|
||||
payload.paypal_client_secret = paypalSecret.value
|
||||
}
|
||||
@@ -81,7 +92,7 @@ async function saveConfig() {
|
||||
await api.put('/webstore/config', payload)
|
||||
toast.success('Store configuration saved successfully')
|
||||
isConfigured.value = true
|
||||
paypalSecret.value = '' // Clear after save
|
||||
paypalSecret.value = ''
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error || 'Failed to save configuration'
|
||||
toast.error(message)
|
||||
@@ -96,202 +107,181 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Store class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Store Configuration</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
Configure your integrated webstore and PayPal settings
|
||||
</p>
|
||||
</div>
|
||||
<div class="sc-page">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Management</div>
|
||||
<h1 class="page__title">Store configuration</h1>
|
||||
<p class="page__sub">Configure your integrated webstore and PayPal settings.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="saveConfig"
|
||||
:disabled="saving || isLoading"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
<Button :loading="saving" :disabled="isLoading" icon="save" @click="saveConfig">
|
||||
Save configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="isLoading" class="sc-loading">
|
||||
<EmptyState icon="loader" title="Loading configuration" description="Fetching your store settings…" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state — no config yet -->
|
||||
<Panel v-else-if="!isConfigured && !config.store_name">
|
||||
<EmptyState
|
||||
icon="shopping-cart"
|
||||
title="No store configured"
|
||||
description="Set up your integrated webstore to start selling in-game items, ranks, and currency to your players. Fill out the form below to get started."
|
||||
>
|
||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
||||
<Save v-else class="w-4 h-4" />
|
||||
{{ saving ? 'Saving...' : 'Save Configuration' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
||||
<p class="text-sm text-neutral-500">Loading configuration...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="!isConfigured && !config.store_name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
|
||||
>
|
||||
<Store class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-neutral-200 mb-2">No Store Configured</h3>
|
||||
<p class="text-sm text-neutral-500 mb-6 max-w-md mx-auto">
|
||||
Set up your integrated webstore to start selling in-game items, ranks, and currency to your players.
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Fill out the form below to get started.
|
||||
</p>
|
||||
</div>
|
||||
<template #action>
|
||||
<Button size="sm" variant="outline" icon="arrow-down">Complete the form below</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
|
||||
<!-- Configuration form -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Store enable toggle with warning -->
|
||||
<div
|
||||
class="bg-neutral-900 border rounded-lg p-5"
|
||||
:class="config.enabled ? 'border-oxide-500/50' : 'border-neutral-800'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-neutral-200">Enable Webstore</h2>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Allow players to purchase items from your store
|
||||
</p>
|
||||
<div v-else class="sc-form">
|
||||
<!-- Enable toggle -->
|
||||
<Panel title="Webstore status" subtitle="Allow players to purchase items from your store">
|
||||
<div class="sc-toggle-row">
|
||||
<div class="sc-toggle-row__info">
|
||||
<span class="sc-toggle-row__label">Enable webstore</span>
|
||||
<span class="sc-toggle-row__hint">Players will be able to browse and purchase items when enabled.</span>
|
||||
</div>
|
||||
<button
|
||||
@click="config.enabled = !config.enabled"
|
||||
:class="config.enabled ? 'bg-oxide-600' : 'bg-neutral-700'"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
|
||||
>
|
||||
<span
|
||||
:class="config.enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
></span>
|
||||
</button>
|
||||
<Switch v-model="config.enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Warning when enabled without PayPal -->
|
||||
<div
|
||||
<Alert
|
||||
v-if="showPayPalWarning"
|
||||
class="mt-4 flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
|
||||
tone="warn"
|
||||
title="PayPal configuration required"
|
||||
class="sc-alert"
|
||||
>
|
||||
<AlertTriangle class="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div class="text-xs text-yellow-300">
|
||||
<strong>PayPal configuration required.</strong> You must configure PayPal credentials below before the store can process transactions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Configure PayPal credentials in the section below before the store can process transactions.
|
||||
</Alert>
|
||||
</Panel>
|
||||
|
||||
<!-- Basic store info -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Store Information</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Store Name *</label>
|
||||
<input
|
||||
<!-- Store information -->
|
||||
<Panel title="Store information" eyebrow="General">
|
||||
<div class="sc-fields">
|
||||
<Input
|
||||
v-model="config.store_name"
|
||||
type="text"
|
||||
label="Store name"
|
||||
placeholder="My Rust Server Store"
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
:required="true"
|
||||
hint="Displayed to players on the store page"
|
||||
/>
|
||||
<p class="text-xs text-neutral-600 mt-1">Displayed to players on the store page</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
v-model="config.description"
|
||||
placeholder="Welcome to our server store! Support us and get awesome in-game items..."
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
></textarea>
|
||||
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the store page</p>
|
||||
</div>
|
||||
<label class="cc-field">
|
||||
<span class="cc-field__label">Description <span class="sc-opt">(optional)</span></span>
|
||||
<textarea
|
||||
v-model="config.description"
|
||||
class="cc-textarea"
|
||||
rows="3"
|
||||
placeholder="Welcome to our server store! Support us and get awesome in-game items…"
|
||||
/>
|
||||
<span class="cc-field__hint">Brief description shown on the store page.</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Currency</label>
|
||||
<div class="relative">
|
||||
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500 pointer-events-none" />
|
||||
<select
|
||||
v-model="config.currency"
|
||||
class="w-full pl-9 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors appearance-none cursor-pointer"
|
||||
>
|
||||
<option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 mt-1">Currency used for all transactions</p>
|
||||
<Select
|
||||
v-model="config.currency"
|
||||
label="Currency"
|
||||
:options="currencyOptions"
|
||||
hint="Currency used for all transactions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- PayPal configuration -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">PayPal Configuration</h2>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Get your API credentials from the
|
||||
<a
|
||||
href="https://developer.paypal.com/dashboard/"
|
||||
target="_blank"
|
||||
class="text-oxide-400 hover:text-oxide-300 underline"
|
||||
>
|
||||
PayPal Developer Dashboard
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">PayPal Client ID *</label>
|
||||
<input
|
||||
v-model="config.paypal_client_id"
|
||||
type="text"
|
||||
<Panel title="PayPal configuration" eyebrow="Payments" subtitle="Get your API credentials from the PayPal Developer Dashboard.">
|
||||
<div class="sc-fields">
|
||||
<Input
|
||||
v-model="paypalClientId"
|
||||
label="PayPal Client ID"
|
||||
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
:required="true"
|
||||
:mono="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">PayPal Client Secret *</label>
|
||||
<input
|
||||
<Input
|
||||
v-model="paypalSecret"
|
||||
label="PayPal Client Secret"
|
||||
type="password"
|
||||
placeholder="Enter to update (stored encrypted)"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
:mono="true"
|
||||
hint="Stored encrypted. Leave blank to keep existing secret."
|
||||
:required="true"
|
||||
/>
|
||||
<p class="text-xs text-neutral-600 mt-1">
|
||||
Stored encrypted. Leave blank to keep existing secret.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sandbox mode toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-200">Sandbox Mode</p>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Use PayPal sandbox for testing (no real transactions)
|
||||
</p>
|
||||
<!-- Sandbox mode toggle -->
|
||||
<div class="sc-sandbox">
|
||||
<div class="sc-toggle-row">
|
||||
<div class="sc-toggle-row__info">
|
||||
<span class="sc-toggle-row__label">Sandbox mode</span>
|
||||
<span class="sc-toggle-row__hint">Use PayPal sandbox for testing — no real transactions.</span>
|
||||
</div>
|
||||
<Switch v-model="config.sandbox_mode" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="config.sandbox_mode = !config.sandbox_mode"
|
||||
:class="config.sandbox_mode ? 'bg-yellow-600' : 'bg-green-600'"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
|
||||
|
||||
<Alert
|
||||
v-if="!config.sandbox_mode && config.enabled"
|
||||
tone="danger"
|
||||
title="Production mode active"
|
||||
>
|
||||
<span
|
||||
:class="config.sandbox_mode ? 'translate-x-6' : 'translate-x-1'"
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
></span>
|
||||
</button>
|
||||
Real transactions will be processed. Ensure your PayPal credentials are correct.
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Production warning -->
|
||||
<div
|
||||
v-if="!config.sandbox_mode && config.enabled"
|
||||
class="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"
|
||||
>
|
||||
<AlertTriangle class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div class="text-xs text-red-300">
|
||||
<strong>Production mode enabled.</strong> Real transactions will be processed. Ensure your PayPal credentials are correct.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sc-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.page__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 5px;
|
||||
}
|
||||
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||
|
||||
.sc-loading { padding: 20px 0; }
|
||||
|
||||
.sc-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.sc-fields { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* Shared toggle row */
|
||||
.sc-toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.sc-toggle-row__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.sc-toggle-row__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.sc-toggle-row__hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
.sc-sandbox {
|
||||
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||
}
|
||||
|
||||
.sc-alert { margin-top: 14px; }
|
||||
|
||||
.sc-opt { font-weight: 400; color: var(--text-muted); }
|
||||
|
||||
/* Textarea token style */
|
||||
.cc-textarea {
|
||||
width: 100%; min-height: 80px; padding: 9px 11px;
|
||||
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); resize: vertical;
|
||||
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||
line-height: var(--leading-normal); outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { StoreCategory, StoreItem } from '@/types'
|
||||
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -42,17 +50,23 @@ const itemTypes = [
|
||||
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
|
||||
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
|
||||
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
|
||||
{ value: 'command', label: 'Custom Command', example: 'yourplugin.givereward {steam_id}' }
|
||||
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
|
||||
]
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'kit': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'rank': return 'bg-purple-500/15 text-purple-400'
|
||||
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
|
||||
case 'command': return 'bg-oxide-500/15 text-oxide-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
const tabItems = computed(() => [
|
||||
{ value: 'categories', label: 'Categories', count: categories.value.length },
|
||||
{ value: 'items', label: 'Items', count: items.value.length },
|
||||
])
|
||||
|
||||
type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
|
||||
function typeTone(type: string): ItemTypeTone {
|
||||
const map: Record<string, ItemTypeTone> = {
|
||||
kit: 'info',
|
||||
rank: 'accent',
|
||||
currency: 'warn',
|
||||
command: 'accent',
|
||||
}
|
||||
return map[type] ?? 'neutral'
|
||||
}
|
||||
|
||||
function autoGenerateSlug(name: string): string {
|
||||
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
// Validate
|
||||
if (!itemForm.value.name.trim()) {
|
||||
alert('Item name is required')
|
||||
return
|
||||
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
|
||||
function getCategoryName(categoryId: string | null): string {
|
||||
if (!categoryId) return 'Uncategorized'
|
||||
const cat = categories.value.find(c => c.id === categoryId)
|
||||
return cat?.name || 'Unknown'
|
||||
return cat?.name ?? 'Unknown'
|
||||
}
|
||||
|
||||
const selectedTypeExample = computed(() => {
|
||||
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
||||
return type?.example || ''
|
||||
return type?.example ?? ''
|
||||
})
|
||||
|
||||
const categorySelectOptions = computed(() => [
|
||||
{ value: '', label: 'Uncategorized' },
|
||||
...categories.value.map(c => ({ value: c.id, label: c.name }))
|
||||
])
|
||||
|
||||
const categorySelectValue = computed({
|
||||
get: () => itemForm.value.category_id ?? '',
|
||||
set: (v: string) => { itemForm.value.category_id = v || null }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -249,447 +272,408 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<ShoppingBag class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
{{ categories.length }} categories, {{ items.length }} items
|
||||
</p>
|
||||
</div>
|
||||
<div class="si-page">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Management · Store</div>
|
||||
<h1 class="page__title">Store items</h1>
|
||||
<p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="tab === 'categories' ? fetchCategories() : fetchItems()"
|
||||
:disabled="isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
<button
|
||||
v-if="tab === 'categories'"
|
||||
@click="openCategoryModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Category
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="openItemModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Item
|
||||
</button>
|
||||
<div class="page__actions">
|
||||
<IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
|
||||
<Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
|
||||
<Button v-else icon="plus" @click="openItemModal()">Add item</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
|
||||
<button
|
||||
@click="tab = 'categories'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'categories' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Categories
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'items'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'items' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Items
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tab bar + content panel -->
|
||||
<Panel :flush-body="true">
|
||||
<template #actions>
|
||||
<Tabs v-model="tab" :items="tabItems" />
|
||||
</template>
|
||||
|
||||
<!-- Categories Tab -->
|
||||
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- Categories table -->
|
||||
<table v-if="tab === 'categories'" class="si-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th class="si-col-num">Order</th>
|
||||
<th>Visibility</th>
|
||||
<th class="si-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tbody>
|
||||
<tr v-if="categories.length === 0">
|
||||
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading categories...</template>
|
||||
<template v-else>No categories yet. Add one to organize your store items.</template>
|
||||
<td colspan="5" class="si-empty-cell">
|
||||
<EmptyState
|
||||
icon="folder-open"
|
||||
:title="isLoading ? 'Loading…' : 'No categories'"
|
||||
:description="isLoading ? '' : 'Add a category to organize your store items.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ category.name }}</p>
|
||||
<p v-if="category.description" class="text-xs text-neutral-500 truncate max-w-md">{{ category.description }}</p>
|
||||
<tr v-for="category in categories" :key="category.id">
|
||||
<td class="si-cell-primary">
|
||||
<span class="si-name">{{ category.name }}</span>
|
||||
<span v-if="category.description" class="si-sub">{{ category.description }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="category.visible ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<td><span class="si-mono">{{ category.slug }}</span></td>
|
||||
<td class="si-col-num si-mono">{{ category.display_order }}</td>
|
||||
<td>
|
||||
<Badge :tone="category.visible ? 'online' : 'neutral'">
|
||||
{{ category.visible ? 'Visible' : 'Hidden' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="openCategoryModal(category)"
|
||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteCategory(category)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<td class="si-col-actions">
|
||||
<div class="si-row-actions">
|
||||
<IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
|
||||
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(category)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Items Tab -->
|
||||
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- Items table -->
|
||||
<table v-if="tab === 'items'" class="si-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Type</th>
|
||||
<th class="si-col-price">Price</th>
|
||||
<th>Commands</th>
|
||||
<th>Status</th>
|
||||
<th class="si-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tbody>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading items...</template>
|
||||
<template v-else>No items yet. Add items to start selling.</template>
|
||||
<td colspan="7" class="si-empty-cell">
|
||||
<EmptyState
|
||||
icon="shopping-bag"
|
||||
:title="isLoading ? 'Loading…' : 'No items'"
|
||||
:description="isLoading ? '' : 'Add items to start selling.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ item.name }}</p>
|
||||
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td class="si-cell-primary">
|
||||
<span class="si-name">{{ item.name }}</span>
|
||||
<span v-if="item.description" class="si-sub">{{ item.description }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
|
||||
{{ item.item_type }}
|
||||
</span>
|
||||
<td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
|
||||
<td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
|
||||
<td class="si-col-price">
|
||||
<span class="si-price">${{ safeFixed(item.price, 2) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1 text-sm text-neutral-200">
|
||||
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
|
||||
{{ safeFixed(item.price, 2) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="item.enabled ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
|
||||
<td>
|
||||
<Badge :tone="item.enabled ? 'online' : 'neutral'">
|
||||
{{ item.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="openItemModal(item)"
|
||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteItem(item)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<td class="si-col-actions">
|
||||
<div class="si-row-actions">
|
||||
<IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
|
||||
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(item)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Category Modal -->
|
||||
<!-- ===== Category modal ===== -->
|
||||
<div
|
||||
v-if="showCategoryModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="si-modal-backdrop"
|
||||
@click.self="closeCategoryModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full">
|
||||
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2>
|
||||
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="si-modal">
|
||||
<div class="si-modal__head">
|
||||
<h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeCategoryModal" />
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
|
||||
<input
|
||||
v-model="categoryForm.name"
|
||||
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
||||
type="text"
|
||||
placeholder="VIP Kits"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
|
||||
<input
|
||||
v-model="categoryForm.slug"
|
||||
type="text"
|
||||
placeholder="vip-kits"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||
<div class="si-modal__body">
|
||||
<Input
|
||||
v-model="categoryForm.name"
|
||||
label="Name"
|
||||
placeholder="VIP Kits"
|
||||
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
||||
/>
|
||||
<Input
|
||||
v-model="categoryForm.slug"
|
||||
label="Slug (URL-safe)"
|
||||
placeholder="vip-kits"
|
||||
:mono="true"
|
||||
/>
|
||||
<label class="cc-field">
|
||||
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||
<textarea
|
||||
v-model="categoryForm.description"
|
||||
class="cc-textarea"
|
||||
rows="2"
|
||||
placeholder="Premium kits for VIP players"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label>
|
||||
<input
|
||||
v-model.number="categoryForm.display_order"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="categoryForm.visible"
|
||||
type="checkbox"
|
||||
id="category-visible"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
||||
/>
|
||||
<label for="category-visible" class="text-sm text-neutral-300">Visible to customers</label>
|
||||
</div>
|
||||
</label>
|
||||
<Input
|
||||
v-model="categoryForm.display_order as any"
|
||||
label="Display order"
|
||||
type="number"
|
||||
/>
|
||||
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
|
||||
</div>
|
||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeCategoryModal"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveCategory"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
|
||||
</button>
|
||||
<div class="si-modal__foot">
|
||||
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
|
||||
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Modal -->
|
||||
<!-- ===== Item modal ===== -->
|
||||
<div
|
||||
v-if="showItemModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="si-modal-backdrop"
|
||||
@click.self="closeItemModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2>
|
||||
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="si-modal si-modal--wide">
|
||||
<div class="si-modal__head si-modal__head--sticky">
|
||||
<h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeItemModal" />
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label>
|
||||
<input
|
||||
v-model="itemForm.name"
|
||||
type="text"
|
||||
placeholder="VIP Starter Kit"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||
<div class="si-modal__body">
|
||||
<!-- Basic info section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Basic information</div>
|
||||
<Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
|
||||
<label class="cc-field">
|
||||
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||
<textarea
|
||||
v-model="itemForm.description"
|
||||
class="cc-textarea"
|
||||
rows="2"
|
||||
placeholder="Get started with essential gear and resources"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Category</label>
|
||||
<select
|
||||
v-model="itemForm.category_id"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option :value="null">Uncategorized</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<Select
|
||||
v-model="categorySelectValue"
|
||||
label="Category"
|
||||
:options="categorySelectOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label>
|
||||
<div class="relative">
|
||||
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model.number="itemForm.price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="9.99"
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pricing section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Pricing</div>
|
||||
<Input
|
||||
v-model="itemForm.price as any"
|
||||
label="Price (USD)"
|
||||
type="number"
|
||||
prefix="$"
|
||||
placeholder="9.99"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Item Type -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Item type section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Item type</div>
|
||||
<div class="si-type-grid">
|
||||
<button
|
||||
v-for="type in itemTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
class="si-type-btn"
|
||||
:class="itemForm.item_type === type.value ? 'si-type-btn--active' : ''"
|
||||
@click="itemForm.item_type = type.value as any"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||
:class="itemForm.item_type === type.value
|
||||
? 'bg-oxide-500/15 border-oxide-500 text-oxide-400'
|
||||
: 'bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Commands -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3>
|
||||
<button
|
||||
@click="addCommand"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
Add Command
|
||||
</button>
|
||||
<!-- Delivery commands section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label-row">
|
||||
<div class="si-section__label">Delivery commands</div>
|
||||
<Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</Button>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs">
|
||||
<p class="text-neutral-400">Use placeholders:</p>
|
||||
<p class="text-neutral-300 font-mono">{'{steam_id}'} - Player's Steam ID</p>
|
||||
<p class="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p>
|
||||
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
|
||||
<div class="si-cmd-hint">
|
||||
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
|
||||
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{player_name}'}</span> Player's name</p>
|
||||
<p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2">
|
||||
<input
|
||||
<div class="si-commands">
|
||||
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
|
||||
<Input
|
||||
v-model="itemForm.delivery_commands[index]"
|
||||
type="text"
|
||||
:mono="true"
|
||||
placeholder="inventory.giveto {steam_id} rifle.ak 1"
|
||||
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="itemForm.delivery_commands.length > 1"
|
||||
icon="trash-2"
|
||||
label="Remove"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="removeCommand(index)"
|
||||
class="p-2 text-neutral-500 hover:text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
|
||||
<input
|
||||
v-model="itemForm.image_url"
|
||||
type="text"
|
||||
placeholder="https://example.com/image.png"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label>
|
||||
<input
|
||||
v-model.number="itemForm.limit_per_player"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Leave empty for unlimited"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="itemForm.enabled"
|
||||
type="checkbox"
|
||||
id="item-enabled"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
||||
/>
|
||||
<label for="item-enabled" class="text-sm text-neutral-300">Enabled and available for purchase</label>
|
||||
</div>
|
||||
<!-- Additional settings section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Additional settings</div>
|
||||
<Input
|
||||
v-model="itemForm.image_url"
|
||||
label="Image URL"
|
||||
placeholder="https://example.com/image.png"
|
||||
hint="Optional preview image for the store page."
|
||||
/>
|
||||
<Input
|
||||
v-model="itemForm.limit_per_player as any"
|
||||
label="Purchase limit per player"
|
||||
type="number"
|
||||
placeholder="Leave empty for unlimited"
|
||||
hint="Optional. Restricts how many times a player can purchase this item."
|
||||
/>
|
||||
<Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeItemModal"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveItem"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingItem ? 'Save Changes' : 'Create Item' }}
|
||||
</button>
|
||||
<div class="si-modal__foot">
|
||||
<Button variant="secondary" @click="closeItemModal">Cancel</Button>
|
||||
<Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.si-page { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.page__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 5px;
|
||||
}
|
||||
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||
.page__actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Table */
|
||||
.si-table { width: 100%; border-collapse: collapse; }
|
||||
.si-table thead th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.si-table tbody tr { border-bottom: 1px solid var(--border-subtle); }
|
||||
.si-table tbody tr:last-child { border-bottom: none; }
|
||||
.si-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.si-table td { padding: 11px 16px; vertical-align: middle; }
|
||||
|
||||
.si-cell-primary { display: flex; flex-direction: column; gap: 2px; }
|
||||
.si-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.si-sub { font-size: var(--text-xs); color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
|
||||
.si-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
.si-text-secondary { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.si-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
.si-col-num { width: 80px; text-align: right; }
|
||||
.si-col-num.si-mono { text-align: right; }
|
||||
.si-col-price { width: 90px; }
|
||||
.si-col-actions { width: 80px; }
|
||||
.si-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
|
||||
.si-empty-cell { padding: 0 !important; }
|
||||
|
||||
.si-spin { animation: si-spin 0.8s linear infinite; }
|
||||
@keyframes si-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Modal */
|
||||
.si-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
|
||||
}
|
||||
.si-modal {
|
||||
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 520px; width: 100%;
|
||||
display: flex; flex-direction: column; max-height: 90vh;
|
||||
}
|
||||
.si-modal--wide { max-width: 680px; }
|
||||
|
||||
.si-modal__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.si-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
|
||||
.si-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.si-modal__body {
|
||||
padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
|
||||
.si-modal__foot {
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Item modal sections */
|
||||
.si-section { display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-section__label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.si-section__label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
|
||||
/* Item type selector */
|
||||
.si-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.si-type-btn {
|
||||
padding: 9px 14px; font-size: var(--text-sm); font-weight: 500; font-family: var(--font-sans);
|
||||
border-radius: var(--radius-md); border: none; cursor: pointer;
|
||||
background: var(--surface-inset); color: var(--text-secondary);
|
||||
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||
}
|
||||
.si-type-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.si-type-btn--active {
|
||||
background: var(--accent-soft); color: var(--accent-text);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
|
||||
/* Command hint */
|
||||
.si-cmd-hint {
|
||||
background: var(--surface-inset); border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); padding: 10px 13px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.si-cmd-hint__row { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.si-cmd-hint__mono { font-family: var(--font-mono); color: var(--text-primary); margin-right: 6px; }
|
||||
.si-cmd-hint__example { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.si-commands { display: flex; flex-direction: column; gap: 8px; }
|
||||
.si-command-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.si-opt { font-weight: 400; color: var(--text-muted); }
|
||||
|
||||
/* Textarea token style */
|
||||
.cc-textarea {
|
||||
width: 100%; min-height: 68px; padding: 9px 11px;
|
||||
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); resize: vertical;
|
||||
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||
line-height: var(--leading-normal); outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { DollarSign, TrendingUp, Clock, AlertCircle, Download, RefreshCw } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { StoreTransaction } from '@/types'
|
||||
import { safeCurrency, safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -45,15 +50,16 @@ const formatCurrency = (amount: number, currency: string = 'USD'): string => {
|
||||
return safeCurrency(amount, symbol)
|
||||
}
|
||||
|
||||
// Status badge color classes
|
||||
const statusBadgeClass = (status: string): string => {
|
||||
// Status badge tone map
|
||||
type BadgeTone = 'online' | 'warn' | 'info' | 'offline' | 'neutral'
|
||||
const statusTone = (status: string): BadgeTone => {
|
||||
switch (status) {
|
||||
case 'delivered': return 'bg-green-500/10 text-green-400'
|
||||
case 'paid': return 'bg-yellow-500/10 text-yellow-400'
|
||||
case 'pending': return 'bg-blue-500/10 text-blue-400'
|
||||
case 'failed': return 'bg-red-500/10 text-red-400'
|
||||
case 'refunded': return 'bg-neutral-500/10 text-neutral-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
case 'delivered': return 'online'
|
||||
case 'paid': return 'warn'
|
||||
case 'pending': return 'info'
|
||||
case 'failed': return 'offline'
|
||||
case 'refunded': return 'neutral'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +83,10 @@ const loadTransactions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
// Render revenue chart (last 30 days, grouped by day)
|
||||
const renderRevenueChart = () => {
|
||||
if (!revenueChart.value || transactions.value.length === 0) return
|
||||
@@ -106,16 +116,24 @@ const renderRevenueChart = () => {
|
||||
d.setDate(d.getDate() - i)
|
||||
const dateKey = d.toLocaleDateString('en-US')
|
||||
dates.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }))
|
||||
revenueData.push(revenueByDate.get(dateKey) || 0)
|
||||
revenueData.push(revenueByDate.get(dateKey) ?? 0)
|
||||
}
|
||||
|
||||
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||
const axisLine = cssVar('--border-default') || '#404040'
|
||||
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
|
||||
revenueChartInstance.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||
formatter: (params: any) => {
|
||||
const value = params[0]?.data
|
||||
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
|
||||
@@ -131,15 +149,15 @@ const renderRevenueChart = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Revenue ($)',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080', formatter: (value: number) => `$${value}` }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10, formatter: (value: number) => `$${value}` }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -186,163 +204,277 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="revenue-view">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<DollarSign class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Revenue Dashboard</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
<div class="revenue-view__header">
|
||||
<h1 class="revenue-view__title">Revenue dashboard</h1>
|
||||
<div class="revenue-view__controls">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="loading"
|
||||
@click="loadTransactions"
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
@click="exportCSV"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="download"
|
||||
:disabled="transactions.length === 0"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="exportCSV"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading transaction data...</div>
|
||||
<div v-if="loading" class="revenue-view__loading">
|
||||
<span class="revenue-view__loading-text">Loading transaction data...</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<DollarSign class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Total Revenue</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ formatCurrency(totalRevenue) }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last 100 transactions</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Total Transactions</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ totalTransactions }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">All time</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Pending Deliveries</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ pendingDeliveries }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Paid, not delivered</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<AlertCircle class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Refunds</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ refunds }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Total refunded</p>
|
||||
</div>
|
||||
<div class="revenue-view__stats">
|
||||
<StatCard
|
||||
label="Total revenue"
|
||||
:value="formatCurrency(totalRevenue)"
|
||||
icon="dollar-sign"
|
||||
note="Last 100 transactions"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total transactions"
|
||||
:value="totalTransactions"
|
||||
icon="trending-up"
|
||||
note="All time"
|
||||
/>
|
||||
<StatCard
|
||||
label="Pending deliveries"
|
||||
:value="pendingDeliveries"
|
||||
icon="clock"
|
||||
note="Paid, not delivered"
|
||||
/>
|
||||
<StatCard
|
||||
label="Refunds"
|
||||
:value="refunds"
|
||||
icon="alert-circle"
|
||||
note="Total refunded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Chart -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Revenue Over Time (Last 30 Days)</h2>
|
||||
<div ref="revenueChart" class="h-64"></div>
|
||||
</div>
|
||||
<Panel title="Revenue over time (last 30 days)">
|
||||
<div ref="revenueChart" class="revenue-view__chart-area"></div>
|
||||
</Panel>
|
||||
|
||||
<!-- Transaction Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Transaction History</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-neutral-500">Filter:</label>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="px-2 py-1 text-xs bg-neutral-800 border border-neutral-700 rounded text-neutral-300 focus:outline-none focus:border-oxide-500"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Panel title="Transaction history" :flush-body="true">
|
||||
<template #actions>
|
||||
<Select
|
||||
:options="[
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'refunded', label: 'Refunded' }
|
||||
]"
|
||||
v-model="statusFilter"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Delivered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="filteredTransactions.length === 0">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="statusFilter !== 'all'">No {{ statusFilter }} transactions found.</template>
|
||||
<template v-else>No transactions yet. Sales will appear here.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="txn in filteredTransactions"
|
||||
:key="txn.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm text-neutral-300">{{ formatDate(txn.created_at) }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ txn.player_name || 'Unknown' }}</p>
|
||||
<p class="text-xs text-neutral-500 font-mono">{{ txn.steam_id }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm text-neutral-300">{{ txn.item_id || '—' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-200">{{ formatCurrency(txn.amount, txn.currency) }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full uppercase"
|
||||
:class="statusBadgeClass(txn.status)"
|
||||
>
|
||||
{{ txn.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="txn.delivered ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ txn.delivered ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
<p v-if="txn.delivered_at" class="text-xs text-neutral-600 mt-1">
|
||||
{{ formatDate(txn.delivered_at) }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="revenue-view__table-wrap">
|
||||
<table class="revenue-view__table">
|
||||
<thead>
|
||||
<tr class="revenue-view__thead-row">
|
||||
<th class="revenue-view__th revenue-view__th--left">Date</th>
|
||||
<th class="revenue-view__th revenue-view__th--left">Player</th>
|
||||
<th class="revenue-view__th revenue-view__th--left">Item</th>
|
||||
<th class="revenue-view__th revenue-view__th--right">Amount</th>
|
||||
<th class="revenue-view__th revenue-view__th--left">Status</th>
|
||||
<th class="revenue-view__th revenue-view__th--left">Delivered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="filteredTransactions.length === 0">
|
||||
<td colspan="6" class="revenue-view__td-empty">
|
||||
<EmptyState
|
||||
icon="dollar-sign"
|
||||
:title="statusFilter !== 'all' ? `No ${statusFilter} transactions` : 'No transactions yet'"
|
||||
:description="statusFilter !== 'all' ? '' : 'Sales will appear here once customers make purchases.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="txn in filteredTransactions"
|
||||
:key="txn.id"
|
||||
class="revenue-view__row"
|
||||
>
|
||||
<td class="revenue-view__td">
|
||||
<p class="revenue-view__cell-text">{{ formatDate(txn.created_at) }}</p>
|
||||
</td>
|
||||
<td class="revenue-view__td">
|
||||
<p class="revenue-view__cell-primary">{{ txn.player_name || 'Unknown' }}</p>
|
||||
<p class="revenue-view__cell-mono">{{ txn.steam_id }}</p>
|
||||
</td>
|
||||
<td class="revenue-view__td">
|
||||
<p class="revenue-view__cell-text">{{ txn.item_id || '—' }}</p>
|
||||
</td>
|
||||
<td class="revenue-view__td revenue-view__td--right">
|
||||
<p class="revenue-view__cell-primary revenue-view__cell-mono">{{ formatCurrency(txn.amount, txn.currency) }}</p>
|
||||
</td>
|
||||
<td class="revenue-view__td">
|
||||
<Badge :tone="statusTone(txn.status)" uppercase>{{ txn.status }}</Badge>
|
||||
</td>
|
||||
<td class="revenue-view__td">
|
||||
<Badge :tone="txn.delivered ? 'online' : 'neutral'">
|
||||
{{ txn.delivered ? 'Yes' : 'No' }}
|
||||
</Badge>
|
||||
<p v-if="txn.delivered_at" class="revenue-view__cell-sub">
|
||||
{{ formatDate(txn.delivered_at) }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.revenue-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.revenue-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.revenue-view__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.revenue-view__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.revenue-view__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.revenue-view__loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.revenue-view__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.revenue-view__stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.revenue-view__chart-area {
|
||||
height: 256px;
|
||||
}
|
||||
|
||||
.revenue-view__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.revenue-view__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.revenue-view__thead-row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.revenue-view__th {
|
||||
padding: 12px 16px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.revenue-view__th--left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.revenue-view__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.revenue-view__row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.revenue-view__row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.revenue-view__td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.revenue-view__td--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.revenue-view__td-empty {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.revenue-view__cell-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.revenue-view__cell-primary {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.revenue-view__cell-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.revenue-view__cell-sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { BarChart3, TrendingUp, Clock, Target, Download, Zap } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { WipePerformanceMetrics } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -42,9 +47,22 @@ const loadAnalytics = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
if (!metrics.value) return
|
||||
|
||||
const accent = cssVar('--accent') || '#CE422B'
|
||||
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||
const axisLine = cssVar('--border-default') || '#404040'
|
||||
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||
const mono = 'JetBrains Mono, monospace'
|
||||
|
||||
// Success Rate Timeline
|
||||
if (successRateChart.value) {
|
||||
if (successRateChartInstance) {
|
||||
@@ -62,9 +80,9 @@ const renderCharts = () => {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||
formatter: (params: any) => {
|
||||
const status = params[0].data === 1 ? 'Success' : 'Failed'
|
||||
const color = params[0].data === 1 ? '#10b981' : '#ef4444'
|
||||
@@ -81,17 +99,19 @@ const renderCharts = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 1,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: {
|
||||
color: '#808080',
|
||||
color: labelColor,
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => value === 1 ? 'Success' : 'Failed'
|
||||
}
|
||||
},
|
||||
@@ -122,9 +142,9 @@ const renderCharts = () => {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' }
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
@@ -136,15 +156,15 @@ const renderCharts = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Day 1', 'Day 2', 'Day 3'],
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Avg Players',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -157,8 +177,8 @@ const renderCharts = () => {
|
||||
],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#CE422B' },
|
||||
{ offset: 1, color: '#8B2E1F' }
|
||||
{ offset: 0, color: accent },
|
||||
{ offset: 1, color: accent + '99' }
|
||||
])
|
||||
},
|
||||
barWidth: '50%'
|
||||
@@ -184,9 +204,9 @@ const renderCharts = () => {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
backgroundColor: tooltipBg,
|
||||
borderColor: tooltipBorder,
|
||||
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||
formatter: (params: any) => {
|
||||
return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes`
|
||||
}
|
||||
@@ -201,15 +221,15 @@ const renderCharts = () => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
axisLabel: { color: '#808080', rotate: 45 }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Minutes',
|
||||
axisLine: { lineStyle: { color: '#404040' } },
|
||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||
axisLabel: { color: '#808080' }
|
||||
axisLine: { lineStyle: { color: axisLine } },
|
||||
splitLine: { lineStyle: { color: grid } },
|
||||
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -264,128 +284,190 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="wipe-analytics-view">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Zap class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Analytics</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="downloadCSV"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||
>
|
||||
<Download class="w-4 h-4" />
|
||||
<div class="wipe-analytics-view__header">
|
||||
<h1 class="wipe-analytics-view__title">Wipe analytics</h1>
|
||||
<div class="wipe-analytics-view__controls">
|
||||
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||
Export CSV
|
||||
</button>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['6', '12', 'all'] as const)"
|
||||
:key="opt"
|
||||
@click="timeRange = opt"
|
||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt === 'all' ? 'All Time' : `Last ${opt} Wipes` }}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Tabs
|
||||
:items="[
|
||||
{ value: '6', label: 'Last 6 wipes' },
|
||||
{ value: '12', label: 'Last 12 wipes' },
|
||||
{ value: 'all', label: 'All time' }
|
||||
]"
|
||||
v-model="timeRange"
|
||||
variant="pill"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-neutral-500">Loading wipe analytics...</div>
|
||||
<div v-if="loading" class="wipe-analytics-view__loading">
|
||||
<span class="wipe-analytics-view__loading-text">Loading wipe analytics...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="metrics">
|
||||
<!-- Insight Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Target class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Success Rate</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(metrics?.success_rate_percent, 1) }}%</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Avg Duration</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ formatDuration(metrics.avg_duration_seconds) }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Per wipe</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Peak Population</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">Day 1</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ safeFixed(metrics?.population_curve?.day_1_avg, 1) }} avg players</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Optimal Timing</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ metrics.optimal_wipe_day }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.optimal_wipe_hour }}:00</p>
|
||||
</div>
|
||||
<div class="wipe-analytics-view__stats">
|
||||
<StatCard
|
||||
label="Success rate"
|
||||
:value="safeFixed(metrics?.success_rate_percent, 1)"
|
||||
unit="%"
|
||||
icon="target"
|
||||
:note="`${metrics.successful_wipes}/${metrics.total_wipes} wipes`"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg duration"
|
||||
:value="formatDuration(metrics.avg_duration_seconds)"
|
||||
icon="clock"
|
||||
note="Per wipe"
|
||||
/>
|
||||
<StatCard
|
||||
label="Peak population"
|
||||
value="Day 1"
|
||||
icon="trending-up"
|
||||
:note="`${safeFixed(metrics?.population_curve?.day_1_avg, 1)} avg players`"
|
||||
/>
|
||||
<StatCard
|
||||
label="Optimal timing"
|
||||
:value="metrics.optimal_wipe_day"
|
||||
icon="bar-chart-3"
|
||||
:note="`${metrics.optimal_wipe_hour}:00`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actionable Insight Banner -->
|
||||
<div v-if="metrics.total_wipes > 3" class="bg-oxide-500/10 border border-oxide-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Target class="w-5 h-5 text-oxide-400 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-100 mb-1">Recommendations</p>
|
||||
<ul class="text-sm text-neutral-300 space-y-1">
|
||||
<li>• Your best wipe day is <span class="font-semibold text-oxide-400">{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</span> based on post-wipe population peaks.</li>
|
||||
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
|
||||
• Players peak on Day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <span class="font-semibold text-oxide-400">weekly wipes</span> to maintain engagement.
|
||||
</li>
|
||||
<li v-if="metrics.avg_duration_seconds > 600">
|
||||
• Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
|
||||
</li>
|
||||
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
|
||||
• {{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="metrics.total_wipes > 3"
|
||||
tone="accent"
|
||||
title="Recommendations"
|
||||
>
|
||||
<ul class="wipe-analytics-view__recs">
|
||||
<li>Your best wipe day is <strong>{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</strong> based on post-wipe population peaks.</li>
|
||||
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
|
||||
Players peak on day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <strong>weekly wipes</strong> to maintain engagement.
|
||||
</li>
|
||||
<li v-if="metrics.avg_duration_seconds > 600">
|
||||
Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
|
||||
</li>
|
||||
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
|
||||
{{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Wipe Success Timeline</h2>
|
||||
<div ref="successRateChart" class="h-64"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Population Curve Post-Wipe</h2>
|
||||
<div ref="populationCurveChart" class="h-64"></div>
|
||||
</div>
|
||||
<div class="wipe-analytics-view__charts">
|
||||
<Panel title="Wipe success timeline">
|
||||
<div ref="successRateChart" class="wipe-analytics-view__chart-area"></div>
|
||||
</Panel>
|
||||
<Panel title="Population curve post-wipe">
|
||||
<div ref="populationCurveChart" class="wipe-analytics-view__chart-area"></div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Wipe Duration Trend</h2>
|
||||
<div ref="durationTrendChart" class="h-64"></div>
|
||||
</div>
|
||||
<Panel title="Wipe duration trend">
|
||||
<div ref="durationTrendChart" class="wipe-analytics-view__chart-area"></div>
|
||||
</Panel>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-if="metrics.total_wipes === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
||||
<div class="text-center">
|
||||
<Zap class="w-12 h-12 text-neutral-700 mx-auto mb-3" />
|
||||
<p class="text-neutral-400 mb-1">No wipe data yet</p>
|
||||
<p class="text-sm text-neutral-600">Wipe analytics will appear after your first scheduled or manual wipe.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Panel v-if="metrics.total_wipes === 0">
|
||||
<EmptyState
|
||||
icon="zap"
|
||||
title="No wipe data yet"
|
||||
description="Wipe analytics will appear after your first scheduled or manual wipe."
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wipe-analytics-view {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.wipe-analytics-view__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wipe-analytics-view__stats {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.wipe-analytics-view__charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wipe-analytics-view__charts {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.wipe-analytics-view__chart-area {
|
||||
height: 256px;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__recs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wipe-analytics-view__recs li::before {
|
||||
content: '• ';
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { ref, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { WipeProfile } from '@/types'
|
||||
import { FileText, Plus, ChevronDown, ChevronRight, Edit2, Trash2, X } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const toast = useToastStore()
|
||||
@@ -135,288 +141,489 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
|
||||
<div class="profiles">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Operations</div>
|
||||
<h1 class="page__title">Wipe profiles</h1>
|
||||
</div>
|
||||
<button
|
||||
@click="openCreateModal"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
<Button icon="plus" @click="openCreateModal">New profile</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<Panel v-if="wipeStore.profiles.length === 0">
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="No wipe profiles"
|
||||
description="Create a profile to define pre-wipe and post-wipe behavior."
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Profile
|
||||
</button>
|
||||
</div>
|
||||
<template #action>
|
||||
<Button icon="plus" size="sm" @click="openCreateModal">New profile</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
|
||||
<!-- Profiles -->
|
||||
<div v-if="wipeStore.profiles.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<FileText class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Wipe Profiles</h3>
|
||||
<p class="text-sm text-neutral-500">Create a profile to define pre-wipe and post-wipe behavior.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
<!-- Profile list -->
|
||||
<div v-else class="profile-list">
|
||||
<Panel
|
||||
v-for="profile in wipeStore.profiles"
|
||||
:key="profile.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
|
||||
:flush-body="true"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template #title-append>
|
||||
<!-- Nothing — title is injected via Panel slot at row level instead -->
|
||||
</template>
|
||||
|
||||
<!-- Accordion row header -->
|
||||
<div class="profile-row">
|
||||
<button
|
||||
type="button"
|
||||
class="profile-toggle"
|
||||
@click="toggle(profile.id)"
|
||||
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p>
|
||||
<div class="profile-meta">
|
||||
<span class="profile-name">{{ profile.profile_name }}</span>
|
||||
<span class="profile-desc">{{ profile.description || 'No description' }}</span>
|
||||
</div>
|
||||
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
|
||||
<span class="profile-chev" :class="expandedId === profile.id && 'profile-chev--open'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1 pr-4">
|
||||
<button
|
||||
@click="openEditModal(profile)"
|
||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteProfile(profile)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="profile-actions">
|
||||
<IconButton icon="pencil" size="sm" label="Edit" @click="openEditModal(profile)" />
|
||||
<IconButton icon="trash-2" variant="danger" size="sm" label="Delete" @click="deleteProfile(profile)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Expanded detail -->
|
||||
<div v-if="expandedId === profile.id" class="profile-detail">
|
||||
<div class="detail-grid">
|
||||
<!-- Pre-wipe -->
|
||||
<div>
|
||||
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Pre-Wipe</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Backup before wipe</span>
|
||||
<span :class="profile.pre_wipe_config.backup_before_wipe ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="t-eyebrow detail-eyebrow">Pre-wipe</div>
|
||||
<div class="detail-rows">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Backup before wipe</span>
|
||||
<Badge :tone="profile.pre_wipe_config.backup_before_wipe ? 'online' : 'neutral'">
|
||||
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Kick players</span>
|
||||
<span :class="profile.pre_wipe_config.kick_players_before_wipe ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Kick players</span>
|
||||
<Badge :tone="profile.pre_wipe_config.kick_players_before_wipe ? 'online' : 'neutral'">
|
||||
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Final save</span>
|
||||
<span :class="profile.pre_wipe_config.run_final_save ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Final save</span>
|
||||
<Badge :tone="profile.pre_wipe_config.run_final_save ? 'online' : 'neutral'">
|
||||
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Discord announce</span>
|
||||
<span :class="profile.pre_wipe_config.discord_pre_announce ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Discord announce</span>
|
||||
<Badge :tone="profile.pre_wipe_config.discord_pre_announce ? 'online' : 'neutral'">
|
||||
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post-wipe -->
|
||||
<div>
|
||||
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Post-Wipe</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Verify server started</span>
|
||||
<span :class="profile.post_wipe_config.verify_server_started ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="t-eyebrow detail-eyebrow">Post-wipe</div>
|
||||
<div class="detail-rows">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Verify server started</span>
|
||||
<Badge :tone="profile.post_wipe_config.verify_server_started ? 'online' : 'neutral'">
|
||||
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Verify plugins loaded</span>
|
||||
<span :class="profile.post_wipe_config.verify_plugins_loaded ? 'text-green-400' : 'text-neutral-600'">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Verify plugins loaded</span>
|
||||
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
|
||||
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Rollback on failure</span>
|
||||
<span :class="profile.post_wipe_config.rollback_on_failure ? 'text-oxide-400' : 'text-neutral-600'">
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Rollback on failure</span>
|
||||
<Badge :tone="profile.post_wipe_config.rollback_on_failure ? 'accent' : 'neutral'">
|
||||
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Max restart attempts</span>
|
||||
<span class="text-neutral-300">{{ profile.post_wipe_config.max_restart_attempts }}</span>
|
||||
<div class="detail-kv">
|
||||
<span class="detail-k">Max restart attempts</span>
|
||||
<span class="detail-v-mono">{{ profile.post_wipe_config.max_restart_attempts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Modal header -->
|
||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2>
|
||||
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="modal-backdrop"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="modal">
|
||||
<!-- Modal header -->
|
||||
<div class="modal__head">
|
||||
<h2 class="modal__title">{{ editingProfile ? 'Edit profile' : 'New profile' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeModal" />
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
|
||||
<input
|
||||
<div class="modal__body">
|
||||
<!-- Basic info -->
|
||||
<div class="form-section">
|
||||
<div class="t-eyebrow form-section__eyebrow">Basic information</div>
|
||||
<Input
|
||||
v-model="form.profile_name"
|
||||
type="text"
|
||||
placeholder="Default Wipe Profile"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
label="Profile name"
|
||||
placeholder="Default wipe profile"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="cc-field">
|
||||
<span class="cc-field__label">Description (optional)</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="2"
|
||||
placeholder="Standard wipe configuration for monthly force wipes"
|
||||
class="cc-textarea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="2"
|
||||
placeholder="Standard wipe configuration for monthly force wipes"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-Wipe Config -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.backup_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Backup before wipe
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.run_final_save" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Run final save
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.kick_players_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Kick players before wipe
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.discord_pre_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Discord pre-announce
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.pre_wipe_config.pushbullet_notify" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Pushbullet notify
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="form.pre_wipe_config.kick_players_before_wipe">
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label>
|
||||
<input
|
||||
<!-- Pre-wipe config -->
|
||||
<div class="form-section">
|
||||
<div class="form-section__row">
|
||||
<div class="t-eyebrow">Pre-wipe</div>
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.enabled"
|
||||
label="Enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="check-grid">
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.backup_before_wipe"
|
||||
label="Backup before wipe"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.run_final_save"
|
||||
label="Run final save"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.kick_players_before_wipe"
|
||||
label="Kick players before wipe"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.discord_pre_announce"
|
||||
label="Discord pre-announce"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.pre_wipe_config.pushbullet_notify"
|
||||
label="Pushbullet notify"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-if="form.pre_wipe_config.kick_players_before_wipe"
|
||||
v-model="form.pre_wipe_config.kick_message"
|
||||
type="text"
|
||||
label="Kick message"
|
||||
placeholder="Server is wiping, back soon!"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post-Wipe Config -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.verify_server_started" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Verify server started
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.verify_correct_map" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Verify correct map
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.verify_plugins_loaded" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Verify plugins loaded
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.verify_player_slots_open" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Verify player slots open
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.rollback_on_failure" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Rollback on failure
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||
<input v-model="form.post_wipe_config.discord_post_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||
Discord post-announce
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label>
|
||||
<input
|
||||
v-model.number="form.post_wipe_config.max_restart_attempts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
<!-- Post-wipe config -->
|
||||
<div class="form-section">
|
||||
<div class="form-section__row">
|
||||
<div class="t-eyebrow">Post-wipe</div>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.enabled"
|
||||
label="Enabled"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label>
|
||||
<input
|
||||
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
<div class="check-grid">
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.verify_server_started"
|
||||
label="Verify server started"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.verify_correct_map"
|
||||
label="Verify correct map"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.verify_plugins_loaded"
|
||||
label="Verify plugins loaded"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.verify_player_slots_open"
|
||||
label="Verify player slots open"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.rollback_on_failure"
|
||||
label="Rollback on failure"
|
||||
/>
|
||||
<Checkbox
|
||||
v-model="form.post_wipe_config.discord_post_announce"
|
||||
label="Discord post-announce"
|
||||
/>
|
||||
</div>
|
||||
<div class="number-grid">
|
||||
<div class="cc-field">
|
||||
<span class="cc-field__label">Max restart attempts</span>
|
||||
<span class="cc-input cc-input--mono">
|
||||
<input
|
||||
v-model.number="form.post_wipe_config.max_restart_attempts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cc-field">
|
||||
<span class="cc-field__label">Health check timeout (s)</span>
|
||||
<span class="cc-input cc-input--mono">
|
||||
<input
|
||||
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal footer -->
|
||||
<div class="sticky bottom-0 bg-neutral-900 border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveProfile"
|
||||
:disabled="isSaving"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
{{ isSaving ? 'Saving...' : (editingProfile ? 'Save Changes' : 'Create Profile') }}
|
||||
</button>
|
||||
<!-- Modal footer -->
|
||||
<div class="modal__foot">
|
||||
<Button variant="secondary" @click="closeModal">Cancel</Button>
|
||||
<Button
|
||||
:loading="isSaving"
|
||||
@click="saveProfile"
|
||||
>
|
||||
{{ editingProfile ? 'Save changes' : 'Create profile' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profiles {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Profile list */
|
||||
.profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Profile row inside panel */
|
||||
.profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.profile-toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 12px;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.profile-toggle:hover { background: var(--surface-hover); }
|
||||
.profile-meta { display: flex; flex-direction: column; gap: 2px; }
|
||||
.profile-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.profile-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.profile-chev {
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--dur-base) var(--ease-standard);
|
||||
display: flex;
|
||||
}
|
||||
.profile-chev--open { transform: rotate(180deg); }
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* Expanded detail */
|
||||
.profile-detail {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 16px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.detail-eyebrow { margin-bottom: 10px; }
|
||||
.detail-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-k {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.detail-v-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.modal__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.modal__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.modal__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Form sections */
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.form-section__eyebrow { margin-bottom: 4px; }
|
||||
.form-section__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.number-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Textarea token-based */
|
||||
.cc-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: 0;
|
||||
transition: var(--transition-colors);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.check-grid { grid-template-columns: 1fr; }
|
||||
.number-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,15 @@ import { useWipeStore } from '@/stores/wipe'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
@@ -65,10 +71,32 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const WIPE_TYPE_OPTIONS = [
|
||||
{ value: 'map', label: 'Map' },
|
||||
{ value: 'blueprint', label: 'Blueprint' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
]
|
||||
|
||||
function profileOptions() {
|
||||
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
|
||||
for (const p of wipeStore.profiles) {
|
||||
opts.push({ value: p.id, label: p.profile_name })
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
function wipeTone(status: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||
if (status === 'success') return 'online'
|
||||
if (status === 'failed' || status === 'rolled_back') return 'offline'
|
||||
if (status === 'wiping' || status === 'pre_wipe' || status === 'post_wipe') return 'warn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await wipeStore.fetchProfiles()
|
||||
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
||||
selectedProfileId.value = wipeStore.profiles[0].id
|
||||
const first = wipeStore.profiles[0]
|
||||
if (first) {
|
||||
selectedProfileId.value = first.id
|
||||
}
|
||||
wipeStore.fetchSchedules()
|
||||
wipeStore.fetchHistory()
|
||||
@@ -76,220 +104,413 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<RefreshCw class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Auto-Wiper</h1>
|
||||
<div class="wipes">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Operations</div>
|
||||
<h1 class="page__title">Auto-wiper</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/wipes/profiles"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Profiles
|
||||
<div class="page__actions">
|
||||
<RouterLink to="/wipes/profiles">
|
||||
<Button variant="secondary" size="sm" icon="file-text">Profiles</Button>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/wipes/calendar"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Calendar
|
||||
<RouterLink to="/wipes/calendar">
|
||||
<Button variant="secondary" size="sm" icon="calendar">Calendar</Button>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/wipes/history"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
History
|
||||
<RouterLink to="/wipes/history">
|
||||
<Button variant="secondary" size="sm" icon="clock">History</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Trigger -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Manual Wipe</h2>
|
||||
<div v-if="wipeStore.profiles.length === 0" class="mb-4 flex items-center gap-2 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2">
|
||||
<AlertTriangle class="w-4 h-4 shrink-0" />
|
||||
No wipe profiles found. <RouterLink to="/wipes/profiles" class="underline hover:text-yellow-300 ml-1">Create a profile</RouterLink> before triggering a wipe.
|
||||
</div>
|
||||
<div class="flex items-end gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in (['map', 'blueprint', 'full'] as const)"
|
||||
:key="opt"
|
||||
@click="triggerType = opt"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
||||
:class="triggerType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
<!-- Manual trigger -->
|
||||
<Panel title="Manual wipe" subtitle="Trigger an immediate wipe outside the schedule">
|
||||
<div class="trigger-body">
|
||||
<Alert
|
||||
v-if="wipeStore.profiles.length === 0"
|
||||
tone="warn"
|
||||
title="No wipe profiles"
|
||||
>
|
||||
<template #default>
|
||||
Create a profile before triggering a wipe.
|
||||
</template>
|
||||
<template #actions>
|
||||
<RouterLink to="/wipes/profiles">
|
||||
<Button variant="outline" size="sm" icon="plus">New profile</Button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<div class="trigger-row">
|
||||
<!-- Wipe type segment -->
|
||||
<div class="trigger-field">
|
||||
<div class="cc-field__label">Wipe type</div>
|
||||
<div class="type-seg">
|
||||
<button
|
||||
v-for="opt in WIPE_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="type-seg__btn"
|
||||
:class="triggerType === opt.value && 'type-seg__btn--active'"
|
||||
@click="triggerType = opt.value as 'map' | 'blueprint' | 'full'"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile select -->
|
||||
<Select
|
||||
label="Profile"
|
||||
:options="profileOptions()"
|
||||
:disabled="wipeStore.profiles.length === 0"
|
||||
:model-value="selectedProfileId"
|
||||
@update:model-value="selectedProfileId = $event ?? ''"
|
||||
/>
|
||||
|
||||
<div class="trigger-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="flask-conical"
|
||||
:loading="dryRunLoading"
|
||||
:disabled="wipeStore.profiles.length === 0"
|
||||
@click="triggerDryRun"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
Dry run
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon="zap"
|
||||
:loading="triggerLoading"
|
||||
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
|
||||
@click="triggerWipe"
|
||||
>
|
||||
Trigger wipe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-2">Profile</label>
|
||||
<select
|
||||
v-model="selectedProfileId"
|
||||
:disabled="wipeStore.profiles.length === 0"
|
||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="">No profile</option>
|
||||
<option v-for="profile in wipeStore.profiles" :key="profile.id" :value="profile.id">
|
||||
{{ profile.profile_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
@click="triggerDryRun"
|
||||
:disabled="dryRunLoading || wipeStore.profiles.length === 0"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 border border-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
|
||||
<AlertTriangle v-else class="w-4 h-4" />
|
||||
Dry Run
|
||||
</button>
|
||||
<button
|
||||
@click="triggerWipe"
|
||||
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />
|
||||
<Zap v-else class="w-4 h-4" />
|
||||
Trigger Wipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Dry-Run Results -->
|
||||
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-neutral-500">
|
||||
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
|
||||
</span>
|
||||
<button
|
||||
@click="dryRunResult = null"
|
||||
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Dry-run results -->
|
||||
<Panel
|
||||
v-if="dryRunResult"
|
||||
title="Dry-run results"
|
||||
:subtitle="`Estimated duration: ${Math.round(dryRunResult.estimated_duration_seconds)}s`"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="ghost" size="sm" icon="x" @click="dryRunResult = null">Dismiss</Button>
|
||||
</template>
|
||||
<div class="dry-run-grid">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
Would Delete ({{ dryRunResult.would_delete.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<div class="dry-run__head dry-run__head--delete">
|
||||
Would delete ({{ dryRunResult.would_delete.length }})
|
||||
</div>
|
||||
<div v-if="dryRunResult.would_delete.length === 0" class="dry-run__empty">
|
||||
Nothing to delete
|
||||
</div>
|
||||
<ul v-else class="dry-run__list">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_delete"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
|
||||
class="dry-run__item dry-run__item--delete"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Would Preserve ({{ dryRunResult.would_preserve.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<div class="dry-run__head dry-run__head--keep">
|
||||
Would preserve ({{ dryRunResult.would_preserve.length }})
|
||||
</div>
|
||||
<div v-if="dryRunResult.would_preserve.length === 0" class="dry-run__empty">
|
||||
Nothing preserved
|
||||
</div>
|
||||
<ul v-else class="dry-run__list">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_preserve"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
|
||||
class="dry-run__item dry-run__item--keep"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Upcoming Schedules -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Scheduled Wipes</h2>
|
||||
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 py-4 text-center">
|
||||
No wipe schedules configured. Create a profile and schedule to automate wipes.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<!-- Scheduled wipes -->
|
||||
<Panel title="Scheduled wipes" subtitle="Active cron schedules">
|
||||
<EmptyState
|
||||
v-if="wipeStore.schedules.length === 0"
|
||||
icon="calendar-clock"
|
||||
title="No schedules"
|
||||
description="Create a profile and schedule to automate wipes."
|
||||
/>
|
||||
<div v-else class="sched-list">
|
||||
<div
|
||||
v-for="schedule in wipeStore.schedules"
|
||||
:key="schedule.id"
|
||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
||||
class="sched-row"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-200">{{ schedule.schedule_name }}</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ schedule.wipe_type }} wipe · {{ schedule.cron_expression }} ({{ schedule.timezone }})
|
||||
</p>
|
||||
<div class="sched-info">
|
||||
<div class="sched-name">{{ schedule.schedule_name }}</div>
|
||||
<div class="sched-meta">
|
||||
{{ schedule.wipe_type }} wipe
|
||||
·
|
||||
<span class="mono">{{ schedule.cron_expression }}</span>
|
||||
· {{ schedule.timezone }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<div class="sched-controls">
|
||||
<Badge :tone="schedule.is_active ? 'online' : 'neutral'">
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<button
|
||||
@click="toggleSchedule(schedule.id, schedule.is_active)"
|
||||
</Badge>
|
||||
<Switch
|
||||
:model-value="schedule.is_active"
|
||||
:disabled="scheduleToggling === schedule.id"
|
||||
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
|
||||
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
|
||||
>
|
||||
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
@update:model-value="toggleSchedule(schedule.id, schedule.is_active)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Recent History -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Recent Wipes</h2>
|
||||
<RouterLink to="/wipes/history" class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
||||
View All
|
||||
<!-- Recent history -->
|
||||
<Panel :flush-body="true" title="Recent wipes">
|
||||
<template #actions>
|
||||
<RouterLink to="/wipes/history">
|
||||
<Button variant="ghost" size="sm">View all</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="wipeStore.history.length === 0" class="text-sm text-neutral-500 py-4 text-center">
|
||||
No wipe history yet.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="wipe in wipeStore.history.slice(0, 5)"
|
||||
:key="wipe.id"
|
||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p>
|
||||
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} · {{ safeDate(wipe.started_at, 'Pending') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500/10 text-green-400': wipe.status === 'success',
|
||||
'bg-red-500/10 text-red-400': wipe.status === 'failed' || wipe.status === 'rolled_back',
|
||||
'bg-yellow-500/10 text-yellow-400': wipe.status === 'wiping' || wipe.status === 'pre_wipe' || wipe.status === 'post_wipe',
|
||||
'bg-neutral-700/50 text-neutral-400': wipe.status === 'pending',
|
||||
}"
|
||||
</template>
|
||||
|
||||
<EmptyState
|
||||
v-if="wipeStore.history.length === 0"
|
||||
icon="trash-2"
|
||||
title="No wipe history"
|
||||
description="Wipes will appear here once they run."
|
||||
/>
|
||||
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Trigger</th>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="wipe in wipeStore.history.slice(0, 5)"
|
||||
:key="wipe.id"
|
||||
>
|
||||
{{ wipe.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<td class="td-primary">{{ wipe.wipe_type }} wipe</td>
|
||||
<td>{{ wipe.trigger_type }}</td>
|
||||
<td class="td-mono">{{ safeDate(wipe.started_at, 'Pending') }}</td>
|
||||
<td>
|
||||
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wipes {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page__actions a { text-decoration: none; }
|
||||
|
||||
/* Trigger body */
|
||||
.trigger-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.trigger-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.trigger-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.trigger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Wipe type segment */
|
||||
.type-seg {
|
||||
display: flex;
|
||||
background: var(--surface-inset);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.type-seg__btn {
|
||||
height: var(--control-h-md);
|
||||
padding: 0 14px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.type-seg__btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||
.type-seg__btn--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
|
||||
/* Dry-run results */
|
||||
.dry-run-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.dry-run__head {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dry-run__head--delete { color: var(--status-offline); }
|
||||
.dry-run__head--keep { color: var(--status-online); }
|
||||
.dry-run__empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.dry-run__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.dry-run__item {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.dry-run__item--delete {
|
||||
background: var(--status-offline-soft);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||
}
|
||||
.dry-run__item--keep {
|
||||
background: var(--status-online-soft);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--status-online-border);
|
||||
}
|
||||
|
||||
/* Schedule list */
|
||||
.sched-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.sched-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.sched-row:last-child { border-bottom: 0; }
|
||||
.sched-info { flex: 1; min-width: 0; }
|
||||
.sched-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sched-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sched-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dry-run-grid { grid-template-columns: 1fr; }
|
||||
.trigger-row { flex-direction: column; align-items: stretch; }
|
||||
.trigger-actions { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user