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>
285 lines
6.4 KiB
Vue
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>
|