Files
corrosion-admin-panel/frontend/src/views/public/ServerInfoView.vue
Vantz Stockwell 6f783bfac8
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 22s
feat(panel): Beta sweep — multi-game coherence, honesty, UX fixes
Multi-game rebrand (no more Rust-only leftovers): game-neutral setup wizard +
deploy/store defaults; player-id labels driven by game profile (Steam ID only
for Rust); blueprint wipe type + verify-plugins gated to uMod games; oxide
command examples + Rust-only plugin pages (AutoDoors/FurnaceSplitter/BetterChat)
guarded behind mods==='umod' with empty-states for other games.

Honesty: webstore checkout shows coming-soon (backend now 503s); 'integrated
webstore' marketed as coming-soon; Discord references neutralized to
community/webhook; migration FAQ marked in-development; analytics dev phase
labels removed; Network pricing tier set to Custom/Contact (was a confusing
duplicate of Operator); docs/PRICING.md rewritten to match live subscriptions.

UX/bugs: fixed ServerView oxide-status operator-precedence bug; dead 'Deploy
server' button wired; non-functional topbar search removed; alert()/confirm()
replaced with toasts across schedules/alerts/migration/public store+server;
analytics chart arrays null-guarded; production console.logs gated to DEV.

Frontend build (vue-tsc + vite) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:06:10 -04:00

285 lines
6.4 KiB
Vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Logo from '@/components/ds/brand/Logo.vue'
interface ServerInfo {
server_name: string
description: string | null
header_image: string | null
motd: string | null
wipe_schedule: string | null
discord_invite: string | null
player_count: number
max_players: number
mods: string[]
connect_url: string
}
const route = useRoute()
const subdomain = route.params.subdomain as string
const toast = useToastStore()
const serverInfo = ref<ServerInfo | null>(null)
const isLoading = ref(false)
const error = ref('')
async function fetchServerInfo() {
isLoading.value = true
error.value = ''
try {
const response = await fetch(`/api/public/${subdomain}/info`)
if (!response.ok) {
throw new Error('Server not found')
}
serverInfo.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load server info'
} finally {
isLoading.value = false
}
}
function copyConnectUrl() {
if (serverInfo.value?.connect_url) {
navigator.clipboard.writeText(serverInfo.value.connect_url)
toast.success('Connect URL copied to clipboard')
}
}
onMounted(() => {
fetchServerInfo()
})
</script>
<template>
<div class="si-page">
<!-- Loading -->
<div v-if="isLoading" class="si-state">
<Icon name="loader" :size="28" class="si-spin" />
</div>
<!-- Error -->
<div v-else-if="error" class="si-state">
<EmptyState
icon="server"
title="Server not found"
:description="error"
/>
</div>
<!-- Content -->
<template v-else-if="serverInfo">
<!-- Sticky nav bar -->
<header class="si-bar">
<div class="si-bar__inner">
<Logo :size="22" :wordmark="true" />
</div>
</header>
<main class="si-main">
<!-- Hero image -->
<div v-if="serverInfo.header_image" class="si-hero">
<img
:src="serverInfo.header_image"
:alt="serverInfo.server_name"
class="si-hero__img"
/>
</div>
<!-- Identity + connect -->
<Panel>
<div class="si-identity">
<div class="si-identity__left">
<h1 class="si-title">{{ serverInfo.server_name }}</h1>
<p v-if="serverInfo.description" class="si-desc">
{{ serverInfo.description }}
</p>
</div>
<Button icon="external-link" size="md" @click="copyConnectUrl">
Connect
</Button>
</div>
</Panel>
<!-- KPIs -->
<div class="si-kpis">
<StatCard
icon="users"
label="Players online"
:value="String(serverInfo.player_count)"
:unit="'/' + serverInfo.max_players"
/>
<StatCard
v-if="serverInfo.wipe_schedule"
icon="calendar"
label="Wipe schedule"
:value="serverInfo.wipe_schedule"
/>
</div>
<!-- MOTD -->
<Panel v-if="serverInfo.motd" title="Message of the day">
<p class="si-motd">{{ serverInfo.motd }}</p>
</Panel>
<!-- Active mods -->
<Panel v-if="serverInfo.mods.length > 0" title="Active mods">
<div class="si-mods">
<Badge
v-for="mod in serverInfo.mods"
:key="mod"
tone="neutral"
size="lg"
>{{ mod }}</Badge>
</div>
</Panel>
<!-- Discord -->
<Panel v-if="serverInfo.discord_invite" title="Community">
<Alert tone="info" title="Join our community">
<template #actions>
<a
:href="serverInfo.discord_invite"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="secondary" icon="external-link">Join community</Button>
</a>
</template>
</Alert>
</Panel>
</main>
</template>
</div>
</template>
<style scoped>
.si-page {
min-height: 100vh;
background: var(--surface-canvas);
}
/* Loading / error centering */
.si-state {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* Spinner */
.si-spin {
color: var(--accent);
animation: si-rotate 0.7s linear infinite;
}
@keyframes si-rotate {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.si-spin { animation: none; }
}
/* Sticky brand bar */
.si-bar {
position: sticky;
top: 0;
z-index: 20;
background: var(--surface-base);
box-shadow: 0 1px 0 var(--border-subtle);
}
.si-bar__inner {
max-width: 860px;
margin: 0 auto;
padding: var(--space-4) var(--space-6);
display: flex;
align-items: center;
}
/* Main content */
.si-main {
max-width: 860px;
margin: 0 auto;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* Hero image */
.si-hero {
border-radius: var(--radius-lg);
overflow: hidden;
}
.si-hero__img {
width: 100%;
height: 240px;
object-fit: cover;
display: block;
}
/* Identity block inside panel */
.si-identity {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.si-identity__left {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.si-title {
font-size: var(--text-3xl);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
line-height: 1.1;
}
.si-desc {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
max-width: 560px;
}
/* KPI row */
.si-kpis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
/* MOTD */
.si-motd {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.65;
white-space: pre-line;
}
/* Mods */
.si-mods {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
</style>