feat(redesign): design-system tokens, 23 Vue components, game-aware shell + Fleet/Solo dashboard
All checks were successful
Test Asgard Runner / test (push) Successful in 4s

Tokens ported 1:1 from the Claude Design bundle (colors/game-themes/type/spacing/elevation/motion/fonts) with the data-theme/data-game theming contract via useThemeGame (+ cc-skin-swap repaint guard). 23 design-system components reimplemented as Vue SFCs (core/forms/data/navigation/feedback/brand). DashboardLayout rebuilt as the game-aware shell (GameSwitcher, grouped nav with permission gating preserved, agent-health footer, topbar). DashboardView: Fleet + Solo with per-game GAME_FIELDS rows and the themed ECharts PlayersChart; Solo wired to the real server store, Fleet on representative data pending the multi-instance backend. All four game skins (Rust/Dune/Conan/Soulmask). vue-tsc + vite build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 02:12:35 -04:00
parent ef128b47d2
commit f91ef84832
42 changed files with 3577 additions and 299 deletions

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en" class="dark" data-theme="dark" data-game="rust">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any" />
@@ -9,8 +9,24 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<title>Corrosion Management</title>
<script>
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
so the design-system tokens paint with the right skin from frame one. */
(function () {
try {
var el = document.documentElement;
var t = localStorage.getItem('cc-theme');
var g = localStorage.getItem('cc-game');
if (t === 'dark' || t === 'light') {
el.setAttribute('data-theme', t);
el.classList.toggle('dark', t === 'dark');
}
if (g) el.setAttribute('data-game', g);
} catch (e) {}
})();
</script>
</head>
<body class="bg-neutral-950">
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Corrosion">
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round"></path>
<circle cx="32" cy="32" r="4.4" fill="currentColor"></circle>
</svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* Corrosion brand mark — segmented "C-core" reticle.
* A bold ring split into four arc segments around a centered control node,
* with N/E/S/W targeting ticks. Drawn in `currentColor` so it themes to the
* active accent (set `color: var(--accent)` on a parent) and stays crisp to ~12px.
* Source: design-system assets/mark.svg (64×64 viewBox).
*/
withDefaults(defineProps<{ size?: number | string }>(), { size: 24 })
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Corrosion"
>
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round" />
<circle cx="32" cy="32" r="4.4" fill="currentColor" />
</svg>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* Logo — Corrosion brand lockup.
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
*
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
* parent (or pass `markColor`) to theme it per active game.
*
* Props mirror Logo.jsx exactly:
* size — base px size; drives mark em-size + wordmark scaling
* wordmark — show the "Corrosion" text (default true)
* tagline — false | true (→ "Management Panel") | custom string
* glow — accent drop-shadow for marketing / login hero use
* markColor — force a fixed color on the mark (bypasses currentColor theming)
*/
import { computed } from 'vue'
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
const props = withDefaults(
defineProps<{
size?: number
wordmark?: boolean
tagline?: boolean | string
glow?: boolean
markColor?: string
}>(),
{ size: 26, wordmark: true, tagline: false, glow: false },
)
const gap = computed(() => Math.round(props.size * 0.4) + 'px')
const wordmarkGap = computed(() => Math.round(props.size * 0.14) + 'px')
const wordmarkFontSize = computed(() => (props.size * 0.62) + 'px')
const taglineFontSize = computed(() => Math.max(8, props.size * 0.26) + 'px')
const glowFilter = computed(() =>
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
)
const tagText = computed(() =>
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
)
</script>
<template>
<span
class="cc-logo"
:style="{ display: 'inline-flex', alignItems: 'center', gap, lineHeight: 1 }"
>
<!-- Mark wrapper: sets font-size so CorrosionMark's 1em sizing works; applies glow -->
<span
:style="{
fontSize: size + 'px',
display: 'inline-flex',
filter: glowFilter,
color: markColor ?? undefined,
}"
>
<CorrosionMark :size="size" />
</span>
<!-- Wordmark + optional tagline -->
<span
v-if="wordmark"
:style="{ display: 'inline-flex', flexDirection: 'column', gap: wordmarkGap }"
>
<span
:style="{
fontFamily: 'var(--font-brand)',
fontWeight: 800,
fontSize: wordmarkFontSize,
letterSpacing: '0.005em',
color: 'var(--text-primary)',
lineHeight: 1,
}"
>Corrosion</span>
<span
v-if="tagline"
:style="{
fontFamily: 'var(--font-brand)',
fontWeight: 600,
fontSize: taglineFontSize,
letterSpacing: '0.26em',
textTransform: 'uppercase',
color: 'var(--accent-text)',
lineHeight: 1,
}"
>{{ tagText }}</span>
</span>
</span>
</template>
<style>
.cc-logo { user-select: none; }
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/** Badge — compact status/label chip. Tone drives fg/soft-bg/border; `solid` fills. */
import { computed } from 'vue'
import Icon from './Icon.vue'
import StatusDot from './StatusDot.vue'
const props = withDefaults(
defineProps<{
tone?: 'neutral' | 'accent' | 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping'
solid?: boolean
dot?: boolean
pulse?: boolean
icon?: string
size?: 'md' | 'lg'
mono?: boolean
uppercase?: boolean
}>(),
{ tone: 'neutral', solid: false, dot: false, pulse: false, size: 'md', mono: false, uppercase: false },
)
const NEUTRAL: [string, string, string] = ['var(--text-secondary)', 'var(--surface-raised-2)', 'var(--border-default)']
const TONES: Record<string, [string, string, string]> = {
neutral: NEUTRAL,
accent: ['var(--accent-text)', 'var(--accent-soft)', 'var(--accent-border)'],
online: ['var(--status-online)', 'var(--status-online-soft)', 'var(--status-online-border)'],
offline: ['var(--status-offline)', 'var(--status-offline-soft)', 'var(--status-offline-border)'],
warn: ['var(--status-warn)', 'var(--status-warn-soft)', 'var(--status-warn-border)'],
info: ['var(--status-info)', 'var(--status-info-soft)', 'var(--status-info-border)'],
starting: ['var(--status-starting)', 'var(--status-starting-soft)', 'var(--status-starting-border)'],
wiping: ['var(--status-wiping)', 'var(--status-wiping-soft)', 'var(--status-wiping-border)'],
}
const styleObj = computed(() => {
const [fg, soft, border] = TONES[props.tone] ?? NEUTRAL
return props.solid
? { background: fg, color: 'var(--surface-canvas)', boxShadow: 'none' }
: { background: soft, color: fg, boxShadow: `inset 0 0 0 1px ${border}` }
})
</script>
<template>
<span
class="cc-badge"
:class="[size === 'lg' && 'cc-badge--lg', mono && 'cc-badge--mono', uppercase && 'cc-badge--uppercase']"
:style="styleObj"
>
<StatusDot v-if="dot" :tone="tone" :size="6" :pulse="pulse" />
<Icon v-if="icon" :name="icon" :size="size === 'lg' ? 13 : 12" :stroke-width="2.5" />
<slot />
</span>
</template>
<style>
.cc-badge {
display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px;
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-xs); line-height: 1;
border-radius: var(--radius-sm); white-space: nowrap; letter-spacing: 0.005em;
}
.cc-badge--lg { height: 24px; padding: 0 10px; font-size: var(--text-sm); }
.cc-badge--mono { font-family: var(--font-mono); font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.cc-badge--uppercase { text-transform: uppercase; letter-spacing: var(--tracking-wider); font-size: var(--text-2xs); }
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* Button — primary action control; `variant="primary"` carries the live game accent.
* Variants: primary | secondary | ghost | outline | danger | danger-soft.
* Sizes: sm | md | lg. Pass Lucide names via `icon` / `iconRight`.
* Native click bubbles via attribute fall-through (root is the <button>).
*/
import { computed } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'danger-soft'
size?: 'sm' | 'md' | 'lg'
icon?: string
iconRight?: string
loading?: boolean
block?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}>(),
{ variant: 'primary', size: 'md', loading: false, block: false, disabled: false, type: 'button' },
)
const iconSize = computed(() => (props.size === 'lg' ? 17 : props.size === 'sm' ? 14 : 15))
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="[
'cc-btn',
`cc-btn--${variant}`,
size !== 'md' && `cc-btn--${size}`,
block && 'cc-btn--block',
]"
>
<span v-if="loading" class="cc-btn__spin" />
<Icon v-else-if="icon" :name="icon" :size="iconSize" :stroke-width="2.25" />
<span v-if="$slots.default"><slot /></span>
<Icon v-if="iconRight && !loading" :name="iconRight" :size="iconSize" :stroke-width="2.25" />
</button>
</template>
<style>
.cc-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-sm); line-height: 1;
white-space: nowrap; height: var(--control-h-md); padding: 0 14px;
border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; user-select: none;
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
}
.cc-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.cc-btn:active { transform: translateY(0.5px); }
.cc-btn[disabled], .cc-btn[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
.cc-btn--block { width: 100%; }
.cc-btn--sm { height: var(--control-h-sm); padding: 0 10px; font-size: var(--text-xs); border-radius: var(--radius-sm); gap: 6px; }
.cc-btn--lg { height: var(--control-h-lg); padding: 0 18px; font-size: var(--text-base); gap: 9px; }
.cc-btn--primary { background: var(--accent); color: var(--accent-contrast); }
.cc-btn--primary:hover { background: var(--accent-hover); }
.cc-btn--primary:active { background: var(--accent-press); }
.cc-btn--secondary { background: var(--surface-raised-2); color: var(--text-primary); box-shadow: var(--ring-default); }
.cc-btn--secondary:hover { background: var(--surface-active); }
.cc-btn--ghost { background: transparent; color: var(--text-secondary); }
.cc-btn--ghost:hover { background: var(--surface-hover); color: var(--text-primary); }
.cc-btn--outline { background: transparent; color: var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
.cc-btn--outline:hover { background: var(--accent-soft); }
.cc-btn--danger { background: var(--danger); color: #fff; }
.cc-btn--danger:hover { filter: brightness(1.1); }
.cc-btn--danger-soft { background: var(--status-offline-soft); color: var(--danger); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
.cc-btn--danger-soft:hover { background: var(--danger); color: #fff; }
.cc-btn__spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: cc-btn-spin 0.6s linear infinite; }
@keyframes cc-btn-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
/**
* Icon — renders a Lucide icon by kebab-case name (matches the design system's
* string `icon` prop API, e.g. <Icon name="refresh-cw" />). Maps to
* `lucide-vue-next` via a registry so the bundle only ships icons we use.
* Lucide icons render with `currentColor`, so they theme to the parent's color.
* Add new icons to `registry` as the port grows.
*/
import type { Component } from 'vue'
import { computed } from 'vue'
import {
Play, Pause, RefreshCw, Trash2, Settings, Terminal, Power, Box, Sun, Moon,
Loader, LoaderCircle, TrendingUp, TrendingDown, Minus, Plus, Server, Users,
Puzzle, FolderOpen, Cpu, BarChart3, Rocket, TriangleAlert, Bell, Search,
ChevronDown, ChevronRight, ChevronLeft, ChevronUp, Check, X, Calendar, Clock,
ShoppingCart, CreditCard, HardDrive, Activity, Shield, Download, Upload,
Wifi, WifiOff, Map, Gauge, Gift, Flame, DoorOpen, Pickaxe, Swords, Crosshair,
Navigation, MessageSquare, FileText, Bookmark, ExternalLink, Copy, LogOut,
Eye, EyeOff, Globe, Key, Layers, List, MoreVertical, Zap,
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
} from 'lucide-vue-next'
const props = withDefaults(
defineProps<{ name: string; size?: number; strokeWidth?: number }>(),
{ size: 16, strokeWidth: 2 },
)
const registry: Record<string, Component> = {
play: Play, pause: Pause, 'refresh-cw': RefreshCw, 'trash-2': Trash2,
settings: Settings, terminal: Terminal, power: Power, box: Box, sun: Sun,
moon: Moon, loader: LoaderCircle, 'loader-2': Loader, 'trending-up': TrendingUp,
'trending-down': TrendingDown, minus: Minus, plus: Plus, server: Server,
users: Users, puzzle: Puzzle, 'folder-open': FolderOpen, cpu: Cpu,
'bar-chart-3': BarChart3, rocket: Rocket, 'triangle-alert': TriangleAlert,
bell: Bell, search: Search, 'chevron-down': ChevronDown,
'chevron-right': ChevronRight, 'chevron-left': ChevronLeft, 'chevron-up': ChevronUp,
check: Check, x: X, calendar: Calendar, clock: Clock,
'shopping-cart': ShoppingCart, 'credit-card': CreditCard, 'hard-drive': HardDrive,
activity: Activity, shield: Shield, download: Download, upload: Upload,
wifi: Wifi, 'wifi-off': WifiOff, map: Map, gauge: Gauge, gift: Gift,
flame: Flame, 'door-open': DoorOpen, pickaxe: Pickaxe, swords: Swords,
crosshair: Crosshair, navigation: Navigation, 'message-square': MessageSquare,
'file-text': FileText, bookmark: Bookmark, 'external-link': ExternalLink,
copy: Copy, 'log-out': LogOut, eye: Eye, 'eye-off': EyeOff, globe: Globe,
key: Key, layers: Layers, list: List, 'more-vertical': MoreVertical, zap: Zap,
info: Info, 'octagon-alert': OctagonAlert, 'circle-check': CircleCheck,
sparkles: Sparkles, inbox: Inbox,
'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,
}
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
</script>
<template>
<component
:is="cmp"
v-if="cmp"
class="cc-icon"
:size="size"
:width="size"
:height="size"
:stroke-width="strokeWidth"
aria-hidden="true"
/>
</template>
<style>
.cc-icon { display: inline-block; flex: none; vertical-align: middle; }
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* IconButton — square icon-only action button.
* Variants: ghost | solid | accent | danger.
* Sizes: sm | md | lg.
* Native click bubbles via attribute fall-through (root is <button>).
*/
import { computed } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(
defineProps<{
icon: string
variant?: 'ghost' | 'solid' | 'accent' | 'danger'
size?: 'sm' | 'md' | 'lg'
active?: boolean
label?: string
disabled?: boolean
}>(),
{ variant: 'ghost', size: 'md', active: false, disabled: false },
)
const iconPx = computed(() => (props.size === 'lg' ? 19 : props.size === 'sm' ? 15 : 17))
</script>
<template>
<button
type="button"
:aria-label="label"
:title="label"
:disabled="disabled"
:class="[
'cc-iconbtn',
variant !== 'ghost' && `cc-iconbtn--${variant}`,
size !== 'md' && `cc-iconbtn--${size}`,
active && 'cc-iconbtn--active',
]"
>
<Icon :name="icon" :size="iconPx" :stroke-width="2" />
</button>
</template>
<style>
.cc-iconbtn {
display:inline-flex; align-items:center; justify-content:center; flex:none;
width:var(--control-h-md); height:var(--control-h-md); border-radius:var(--radius-md);
border:1px solid transparent; background:transparent; color:var(--text-secondary);
cursor:pointer; transition:var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
}
.cc-iconbtn:hover { background:var(--surface-hover); color:var(--text-primary); }
.cc-iconbtn:active { transform: translateY(0.5px); }
.cc-iconbtn:focus-visible { outline:none; box-shadow:var(--focus-ring); }
.cc-iconbtn[disabled] { opacity:.4; pointer-events:none; }
.cc-iconbtn--sm { width:var(--control-h-sm); height:var(--control-h-sm); border-radius:var(--radius-sm); }
.cc-iconbtn--lg { width:var(--control-h-lg); height:var(--control-h-lg); }
.cc-iconbtn--solid { background:var(--surface-raised-2); box-shadow:var(--ring-default); }
.cc-iconbtn--solid:hover { background:var(--surface-active); }
.cc-iconbtn--accent { background:var(--accent); color:var(--accent-contrast); }
.cc-iconbtn--accent:hover { background:var(--accent-hover); }
.cc-iconbtn--danger:hover { background:var(--status-offline-soft); color:var(--danger); }
.cc-iconbtn--active { background:var(--accent-soft); color:var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
/**
* Kbd — keyboard shortcut key chip, rendered as <kbd>.
* Uses mono font and inset border + bottom shadow to mimic a physical key.
* No props — purely a presentational slot wrapper; native attrs fall through.
*/
</script>
<template>
<kbd class="cc-kbd"><slot /></kbd>
</template>
<style>
.cc-kbd {
display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px;
font-family:var(--font-mono); font-size:11px; font-weight:500; line-height:1; color:var(--text-secondary);
background:var(--surface-raised-2); border-radius:var(--radius-sm);
box-shadow: inset 0 0 0 1px var(--border-default), 0 1px 0 var(--border-default);
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/** StatusDot — small live-status dot; pulses when live. Tone maps to status tokens. */
withDefaults(
defineProps<{
tone?: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
size?: number
pulse?: boolean
}>(),
{ tone: 'online', size: 8, pulse: false },
)
const TONE: Record<string, string> = {
online: 'var(--status-online)',
offline: 'var(--status-offline)',
warn: 'var(--status-warn)',
info: 'var(--status-info)',
starting: 'var(--status-starting)',
wiping: 'var(--status-wiping)',
neutral: 'var(--text-muted)',
accent: 'var(--accent)',
}
</script>
<template>
<span
class="cc-dot"
:class="pulse && 'cc-dot--pulse'"
:style="{
width: size + 'px',
height: size + 'px',
background: TONE[tone] || TONE.neutral,
boxShadow: pulse ? '0 0 8px -1px ' + (TONE[tone] || TONE.neutral) : 'none',
}"
/>
</template>
<style>
.cc-dot { display: inline-block; flex: none; border-radius: 50%; position: relative; }
.cc-dot--pulse::after {
content: ''; position: absolute; inset: 0; border-radius: 50%; background: inherit;
animation: cc-dot-pulse 1.8s var(--ease-out) infinite;
}
@keyframes cc-dot-pulse {
0% { transform: scale(1); opacity: 0.6; }
70%, 100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .cc-dot--pulse::after { animation: none; } }
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* Tag — removable or static label chip.
* Set `removable` to show the dismiss ×; emit `remove` is fired when clicked.
* Optional `icon` prefix via Icon registry.
*/
import Icon from './Icon.vue'
withDefaults(
defineProps<{
icon?: string
removable?: boolean
}>(),
{ removable: false },
)
const emit = defineEmits<{
remove: []
}>()
</script>
<template>
<span :class="['cc-tag', !removable && 'cc-tag--static']">
<Icon v-if="icon" :name="icon" :size="12" :stroke-width="2.25" />
<span><slot /></span>
<button
v-if="removable"
type="button"
class="cc-tag__x"
aria-label="Remove"
@click="emit('remove')"
>
<Icon name="x" :size="11" :stroke-width="2.5" />
</button>
</span>
</template>
<style>
.cc-tag {
display:inline-flex; align-items:center; gap:6px; height:24px; padding:0 4px 0 9px;
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:500; line-height:1;
color:var(--text-secondary); background:var(--surface-raised-2);
border-radius:var(--radius-sm); box-shadow:var(--ring-default);
}
.cc-tag--static { padding:0 9px; }
.cc-tag__x {
display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px;
border-radius:var(--radius-xs); color:var(--text-tertiary); cursor:pointer; border:0; background:transparent;
transition:var(--transition-colors);
}
.cc-tag__x:hover { background:var(--surface-active); color:var(--text-primary); }
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* Avatar — player / operator avatar. Renders an image, or falls back to initials.
* Optional status dot (online / offline / warn / idle) sits bottom-right.
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
name?: string
src?: string
size?: number
shape?: 'rounded' | 'circle'
status?: 'online' | 'offline' | 'warn' | 'idle'
}>(),
{ name: '', size: 32, shape: 'rounded' },
)
const TONE: Record<string, string> = {
online: 'var(--status-online)',
offline: 'var(--status-offline)',
warn: 'var(--status-warn)',
idle: 'var(--text-muted)',
}
const initials = computed(() =>
props.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map(w => w[0] ?? '')
.join('')
.toUpperCase() || '?',
)
const dotSize = computed(() => Math.max(7, Math.round(props.size * 0.28)))
const dotColor = computed(() => (props.status ? (TONE[props.status] ?? TONE.idle) : ''))
</script>
<template>
<span
class="cc-avatar"
:class="shape === 'circle' && 'cc-avatar--circle'"
:style="{
width: size + 'px',
height: size + 'px',
fontSize: Math.round(size * 0.4) + 'px',
}"
>
<img v-if="src" :src="src" :alt="name" />
<template v-else>{{ initials }}</template>
<span
v-if="status"
class="cc-avatar__status"
:style="{ width: dotSize + 'px', height: dotSize + 'px', background: dotColor }"
/>
</span>
</template>
<style>
.cc-avatar {
position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
border-radius: var(--radius-md); background: var(--surface-active); color: var(--text-secondary);
font-family: var(--font-mono); font-weight: 600; overflow: visible; box-shadow: var(--ring-default);
}
.cc-avatar--circle { border-radius: 50%; }
.cc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.cc-avatar__status {
position: absolute; right: -2px; bottom: -2px; border-radius: 50%;
box-shadow: 0 0 0 2px var(--surface-base);
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* ConsoleLine — one line in the RCON / server log stream.
* Monospace, color-coded by level. Optional timestamp and actor (who).
*/
withDefaults(
defineProps<{
time?: string
level?: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
who?: string
}>(),
{ level: 'info' },
)
const LABEL: Record<string, string> = {
cmd: 'cmd',
chat: 'chat',
info: 'info',
warn: 'warn',
error: 'err',
connect: 'join',
kill: 'kill',
}
</script>
<template>
<div :class="['cc-line', 'cc-line--' + level]">
<span v-if="time" class="cc-line__time">{{ time }}</span>
<span class="cc-line__tag">{{ LABEL[level ?? 'info'] ?? level }}</span>
<span class="cc-line__msg">
<span v-if="who" class="cc-line__who">{{ who }} </span>
<slot />
</span>
</div>
</template>
<style>
.cc-line { display: flex; gap: 10px; padding: 2px 12px; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.65; align-items: baseline; }
.cc-line:hover { background: var(--surface-hover); }
.cc-line__time { color: var(--text-muted); flex: none; font-variant-numeric: tabular-nums; }
.cc-line__tag { flex: none; text-transform: uppercase; font-weight: 600; font-size: 10px; letter-spacing: .05em; padding: 0 5px; border-radius: var(--radius-xs); height: 15px; display: inline-flex; align-items: center; }
.cc-line__msg { color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; min-width: 0; }
.cc-line__who { color: var(--accent-text); }
.cc-line--cmd .cc-line__tag { background: var(--accent-soft); color: var(--accent-text); }
.cc-line--cmd .cc-line__msg { color: var(--text-primary); }
.cc-line--chat .cc-line__tag { background: var(--surface-active); color: var(--text-secondary); }
.cc-line--info .cc-line__tag { background: var(--status-info-soft); color: var(--status-info); }
.cc-line--warn .cc-line__tag { background: var(--status-warn-soft); color: var(--status-warn); }
.cc-line--warn .cc-line__msg { color: var(--status-warn); }
.cc-line--error .cc-line__tag { background: var(--status-offline-soft); color: var(--status-offline); }
.cc-line--error .cc-line__msg { color: var(--status-offline); }
.cc-line--connect .cc-line__tag { background: var(--status-online-soft); color: var(--status-online); }
.cc-line--kill .cc-line__tag { background: var(--status-wiping-soft); color: var(--status-wiping); }
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
/**
* Panel — standard section container.
* Header: optional eyebrow / title / subtitle / right-aligned actions.
* Body: padding removed when flushBody=true (tables / lists manage their own).
*/
withDefaults(
defineProps<{
title?: string
subtitle?: string
eyebrow?: string
variant?: 'base' | 'raised' | 'flush'
flushBody?: boolean
}>(),
{ variant: 'base', flushBody: false },
)
</script>
<template>
<section
:class="[
'cc-panel',
variant === 'raised' && 'cc-panel--raised',
variant === 'flush' && 'cc-panel--flush',
]"
>
<header v-if="title || subtitle || eyebrow || $slots.actions" class="cc-panel__head">
<div class="cc-panel__titles">
<div v-if="eyebrow" class="t-eyebrow">{{ eyebrow }}</div>
<div v-if="title" class="cc-panel__title">
{{ title }}
<slot name="title-append" />
</div>
<div v-if="subtitle" class="cc-panel__sub">{{ subtitle }}</div>
</div>
<div v-if="$slots.actions" class="cc-panel__actions">
<slot name="actions" />
</div>
</header>
<div :class="['cc-panel__body', flushBody && 'cc-panel__body--flush']">
<slot />
</div>
</section>
</template>
<style>
.cc-panel { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); display: flex; flex-direction: column; min-width: 0; }
.cc-panel--raised { background: var(--surface-raised); }
.cc-panel--flush { box-shadow: none; background: transparent; }
.cc-panel__head { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); }
.cc-panel__titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.cc-panel__title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
.cc-panel__sub { font-size: var(--text-xs); color: var(--text-tertiary); }
.cc-panel__actions { display: flex; align-items: center; gap: 6px; flex: none; }
.cc-panel__body { padding: 16px; min-width: 0; }
.cc-panel__body--flush { padding: 0; }
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* PlayersChart — themed ECharts area chart of players online.
* Reads the live design tokens (--accent etc.) from CSS so it matches the
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
*/
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(
defineProps<{ height?: number; data?: number[]; max?: number }>(),
{ height: 200, max: 200 },
)
const el = useTemplateRef<HTMLDivElement>('el')
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
let mo: MutationObserver | null = null
const DEFAULT_SERIES = [
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
]
function cssVar(name: string, node?: HTMLElement): string {
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
}
function render(): void {
if (!chart || !el.value) return
const node = el.value
const accent = cssVar('--accent', node) || '#f26622'
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
const text = cssVar('--text-tertiary', node) || '#767d89'
const mono = 'JetBrains Mono, monospace'
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
const series = props.data ?? DEFAULT_SERIES
chart.setOption({
animationDuration: 700,
grid: { left: 8, right: 12, top: 14, bottom: 22, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: cssVar('--surface-overlay', node) || '#1f2329',
borderColor: cssVar('--border-default', node) || 'rgba(255,255,255,0.1)',
borderWidth: 1,
textStyle: { color: cssVar('--text-primary', node) || '#fff', fontFamily: mono, fontSize: 11 },
axisPointer: { type: 'line', lineStyle: { color: accent, opacity: 0.5 } },
},
xAxis: {
type: 'category', data: hours, boundaryGap: false,
axisLine: { lineStyle: { color: grid } }, axisTick: { show: false },
axisLabel: { color: text, fontFamily: mono, fontSize: 10, interval: 3 },
},
yAxis: {
type: 'value', max: props.max,
splitLine: { lineStyle: { color: grid } },
axisLabel: { color: text, fontFamily: mono, fontSize: 10 },
},
series: [{
type: 'line', smooth: 0.4, symbol: 'none', data: series,
lineStyle: { color: accent, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: accent + '55' },
{ offset: 1, color: accent + '00' },
]),
},
markLine: {
silent: true, symbol: 'none',
lineStyle: { color: text, type: 'dashed', opacity: 0.5 },
data: [{ yAxis: props.max, label: { formatter: `cap ${props.max}`, color: text, fontFamily: mono, fontSize: 9 } }],
},
}],
})
}
onMounted(() => {
if (!el.value) return
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
render()
ro = new ResizeObserver(() => chart?.resize())
ro.observe(el.value)
mo = new MutationObserver(render)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-game', 'data-theme'] })
})
onBeforeUnmount(() => {
ro?.disconnect()
mo?.disconnect()
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/**
* ResourceMeter — labeled utilization bar (CPU, RAM, disk, network).
* tone="auto" colors by threshold: green <70%, amber <90%, red ≥90%.
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label: string
icon?: string
value?: number
sub?: string
tone?: 'auto' | 'ok' | 'warn' | 'danger' | 'accent'
}>(),
{ value: 0, tone: 'auto' },
)
const pct = computed(() => Math.max(0, Math.min(100, props.value)))
const resolvedTone = computed(() => {
if (props.tone !== 'auto') return props.tone
return pct.value >= 90 ? 'danger' : pct.value >= 70 ? 'warn' : 'ok'
})
</script>
<template>
<div :class="['cc-meter', 'cc-meter--' + resolvedTone]">
<div class="cc-meter__top">
<span class="cc-meter__label">{{ label }}</span>
<span class="cc-meter__val">
{{ pct }}%<span v-if="sub" class="cc-meter__sub">{{ sub }}</span>
</span>
</div>
<div class="cc-meter__track">
<div class="cc-meter__fill" :style="{ width: pct + '%' }" />
</div>
</div>
</template>
<style>
.cc-meter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.cc-meter__top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.cc-meter__label { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.cc-meter__val { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.cc-meter__sub { color: var(--text-muted); font-weight: 400; margin-left: 4px; }
.cc-meter__track { height: 6px; border-radius: var(--radius-pill); background: var(--surface-active); overflow: hidden; }
.cc-meter__fill { height: 100%; border-radius: var(--radius-pill); transition: width var(--dur-slow) var(--ease-out), background var(--dur-base); }
.cc-meter--accent .cc-meter__fill { background: var(--accent); }
.cc-meter--ok .cc-meter__fill { background: var(--status-online); }
.cc-meter--warn .cc-meter__fill { background: var(--status-warn); }
.cc-meter--danger .cc-meter__fill { background: var(--status-offline); }
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
/**
* ServerCard — server instance summary card.
* Sets :data-game so per-game accent token re-skins apply via the global [data-game] selector.
* Status drives the dot + left rail color.
* `offline` dims the card and swaps the power IconButton to a Start action.
* Pending state shows when status==='online' && cpu==null && ram==null.
*/
import { computed } from '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 ResourceMeter from './ResourceMeter.vue'
export interface StatItem {
label: string
value: string | number
}
const props = withDefaults(
defineProps<{
game?: string
gameIcon?: string
name: string
region?: string
map?: string
version?: string
status?: 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
players?: { cur: number; max: number }
cpu?: number
ram?: number
ramSub?: string
ip?: string
stats?: StatItem[]
}>(),
{
game: 'rust',
gameIcon: 'box',
status: 'online',
players: () => ({ cur: 0, max: 0 }),
},
)
const emit = defineEmits<{
console: []
settings: []
power: []
}>()
interface StatusEntry {
tone: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
label: string
pulse: boolean
}
const DEFAULT_STATUS: StatusEntry = { tone: 'online', label: 'Online', pulse: true }
const STATUS_MAP: Record<string, StatusEntry> = {
online: { tone: 'online', label: 'Online', pulse: true },
offline: { tone: 'offline', label: 'Offline', pulse: false },
starting: { tone: 'starting', label: 'Booting', pulse: true },
wiping: { tone: 'wiping', label: 'Wiping', pulse: true },
updating: { tone: 'starting', label: 'Updating', pulse: true },
}
const st = computed<StatusEntry>(() => STATUS_MAP[props.status ?? 'online'] ?? DEFAULT_STATUS)
const offline = computed(() => props.status === 'offline')
const statList = computed<StatItem[]>(() => {
if (props.stats) return props.stats
const items: StatItem[] = [
{ label: 'Players', value: `${props.players?.cur ?? 0} / ${props.players?.max ?? 0}` },
]
if (props.version) {
items.push({ label: 'Build', value: props.version })
}
return items
})
const pending = computed(
() => props.status === 'online' && props.cpu == null && props.ram == null,
)
const showMeters = computed(
() => !offline.value && (props.cpu != null || props.ram != null),
)
</script>
<template>
<div
:data-game="game"
:class="['cc-server', offline && 'cc-server--offline']"
>
<!-- Head -->
<div class="cc-server__head">
<div class="cc-server__game">
<Icon :name="gameIcon" :size="18" :stroke-width="2" />
</div>
<div class="cc-server__id">
<div class="cc-server__name">
{{ name }}
<Badge :tone="st.tone" dot :pulse="st.pulse">{{ st.label }}</Badge>
</div>
<div class="cc-server__meta">
<span v-if="region">{{ region }}</span>
<span v-if="map">{{ map }}</span>
<span v-if="ip">{{ ip }}</span>
</div>
</div>
<div class="cc-server__actions">
<IconButton icon="terminal" variant="ghost" size="sm" label="Console" @click="emit('console')" />
<IconButton icon="settings" variant="ghost" size="sm" label="Settings" @click="emit('settings')" />
<IconButton
:icon="offline ? 'play' : 'power'"
:variant="offline ? 'accent' : 'ghost'"
size="sm"
:label="offline ? 'Start' : 'Power'"
@click="emit('power')"
/>
</div>
</div>
<!-- Body -->
<div class="cc-server__body">
<div class="cc-server__stats">
<div v-for="(s, i) in statList" :key="i" class="cc-server__stat">
<b>{{ s.value }}</b>
<span>{{ s.label }}</span>
</div>
</div>
<div v-if="showMeters" class="cc-server__meters">
<ResourceMeter v-if="cpu != null" label="CPU" :value="cpu" />
<ResourceMeter v-if="ram != null" label="RAM" :value="ram" :sub="ramSub" />
</div>
<div v-if="pending" class="cc-server__pending">
<Icon name="loader" :size="13" :stroke-width="2.5" />
Telemetry pending · agent monitoring
</div>
</div>
</div>
</template>
<style>
.cc-server { position: relative; background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); overflow: hidden; transition: var(--transition-colors); }
.cc-server::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .9; }
.cc-server:hover { box-shadow: inset 0 0 0 1px var(--border-strong); }
.cc-server--offline::before { background: var(--status-offline); }
.cc-server--offline { opacity: .82; }
.cc-server__head { display: flex; align-items: center; gap: 12px; padding: 14px 14px 12px 17px; }
.cc-server__game { width: 34px; height: 34px; border-radius: var(--radius-md); flex: none; 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); }
.cc-server__id { flex: 1; min-width: 0; }
.cc-server__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
.cc-server__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; display: flex; gap: 10px; }
.cc-server__body { padding: 0 14px 13px 17px; display: flex; flex-direction: column; gap: 11px; }
.cc-server__stats { display: flex; gap: 18px; }
.cc-server__stat { display: flex; flex-direction: column; gap: 1px; }
.cc-server__stat b { font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
.cc-server__stat span { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
.cc-server__meters { display: flex; gap: 14px; }
.cc-server__meters > * { flex: 1; }
.cc-server__pending { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
.cc-server__pending .cc-icon { color: var(--status-starting); }
.cc-server__actions { display: flex; gap: 5px; }
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* StatCard — KPI tile with icon, big mono value, and optional delta + note row.
* Green delta = up/good, red = down/bad, muted = flat.
*/
import { computed } from 'vue'
import Icon from '@/components/ds/core/Icon.vue'
const props = withDefaults(
defineProps<{
label: string
value: string | number
unit?: string
icon?: string
delta?: string | number
deltaDir?: 'up' | 'down' | 'flat'
note?: string
}>(),
{ deltaDir: 'up' },
)
const deltaIcon = computed(() =>
props.deltaDir === 'up' ? 'trending-up' : props.deltaDir === 'down' ? 'trending-down' : 'minus',
)
const showFoot = computed(() => props.delta != null || !!props.note)
</script>
<template>
<div class="cc-stat">
<div class="cc-stat__top">
<div v-if="icon" class="cc-stat__ico">
<Icon :name="icon" :size="15" :stroke-width="2.25" />
</div>
<div class="cc-stat__label">{{ label }}</div>
</div>
<div class="cc-stat__value">
{{ value }}<span v-if="unit" class="cc-stat__unit">{{ unit }}</span>
</div>
<div v-if="showFoot" class="cc-stat__foot">
<span v-if="delta != null" :class="['cc-stat__delta', 'cc-stat__delta--' + deltaDir]">
<Icon :name="deltaIcon" :size="13" :stroke-width="2.5" />{{ delta }}
</span>
<span v-if="note" class="cc-stat__note">{{ note }}</span>
</div>
</div>
</template>
<style>
.cc-stat { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; position: relative; overflow: hidden; }
.cc-stat__top { display: flex; align-items: center; gap: 8px; }
.cc-stat__ico { width: 28px; height: 28px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-text); flex: none; }
.cc-stat__label { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); letter-spacing: .01em; }
.cc-stat__value { font-family: var(--font-mono); font-weight: 600; font-size: 28px; letter-spacing: -0.02em; color: var(--text-primary); font-variant-numeric: tabular-nums; line-height: 1; display: flex; align-items: baseline; gap: 4px; }
.cc-stat__unit { font-size: 14px; color: var(--text-muted); font-weight: 500; }
.cc-stat__foot { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: var(--text-xs); }
.cc-stat__delta { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; }
.cc-stat__delta--up { color: var(--status-online); }
.cc-stat__delta--down { color: var(--status-offline); }
.cc-stat__delta--flat { color: var(--text-tertiary); }
.cc-stat__note { color: var(--text-tertiary); }
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* Alert — contextual inline alert strip.
* Tones: info | warn | danger | online | accent | neutral.
* Pass `title` for a bold heading, default slot for body text, `actions` slot
* for inline action buttons. Set `dismissible` to show an × ghost button that
* emits `dismiss`.
*/
import Icon from '@/components/ds/core/Icon.vue'
type Tone = 'info' | 'warn' | 'danger' | 'online' | 'accent' | 'neutral'
const ICONS: Record<Tone, string> = {
info: 'info',
warn: 'triangle-alert',
danger: 'octagon-alert',
online: 'circle-check',
accent: 'sparkles',
neutral: 'info',
}
const props = withDefaults(
defineProps<{
tone?: Tone
title?: string
dismissible?: boolean
icon?: string
}>(),
{ tone: 'info', dismissible: false },
)
defineEmits<{ dismiss: [] }>()
</script>
<template>
<div
:class="['cc-alert', 'cc-alert--' + tone]"
role="status"
>
<span class="cc-alert__icon">
<Icon :name="icon ?? ICONS[tone]" :size="17" :stroke-width="2" />
</span>
<div class="cc-alert__main">
<div v-if="title" class="cc-alert__title">{{ title }}</div>
<div v-if="$slots.default" class="cc-alert__body"><slot /></div>
<div v-if="$slots.actions" class="cc-alert__actions"><slot name="actions" /></div>
</div>
<button
v-if="dismissible"
class="cc-alert__dismiss"
type="button"
aria-label="Dismiss"
@click="$emit('dismiss')"
>
<Icon name="x" :size="15" :stroke-width="2.25" />
</button>
</div>
</template>
<style>
.cc-alert { display:flex; gap:11px; padding:12px 13px; border-radius:var(--radius-md); background:var(--surface-raised); box-shadow:var(--ring-default); }
.cc-alert__icon { flex:none; margin-top:1px; }
.cc-alert__main { flex:1; min-width:0; display:flex; flex-direction:column; gap:3px; }
.cc-alert__title { font-size:var(--text-sm); font-weight:600; color:var(--text-primary); }
.cc-alert__body { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; }
.cc-alert__actions { display:flex; gap:8px; margin-top:8px; }
.cc-alert--info { background:var(--status-info-soft); box-shadow: inset 0 0 0 1px var(--status-info-border); }
.cc-alert--info .cc-alert__icon { color:var(--status-info); }
.cc-alert--warn { background:var(--status-warn-soft); box-shadow: inset 0 0 0 1px var(--status-warn-border); }
.cc-alert--warn .cc-alert__icon { color:var(--status-warn); }
.cc-alert--danger { background:var(--status-offline-soft); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
.cc-alert--danger .cc-alert__icon { color:var(--status-offline); }
.cc-alert--online { background:var(--status-online-soft); box-shadow: inset 0 0 0 1px var(--status-online-border); }
.cc-alert--online .cc-alert__icon { color:var(--status-online); }
.cc-alert--accent { background:var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
.cc-alert--accent .cc-alert__icon { color:var(--accent-text); }
.cc-alert__dismiss {
flex: none; display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; border-radius:var(--radius-sm); border:none; cursor:pointer;
background:transparent; color:var(--text-secondary);
transition: var(--transition-colors);
margin-top:-3px; margin-right:-3px;
}
.cc-alert__dismiss:hover { background:var(--surface-hover); color:var(--text-primary); }
.cc-alert__dismiss:focus-visible { outline:none; box-shadow:var(--focus-ring); }
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* EmptyState — zero-data placeholder with icon, title, description, and an
* optional action slot (pass a Button or link).
*
* Icon registry note: default icon 'inbox' is not in the registry — it will
* silently not render per Icon.vue's null guard. Callers should pass a
* registered icon name (e.g. icon="server", icon="folder-open").
*/
import Icon from '@/components/ds/core/Icon.vue'
withDefaults(
defineProps<{
icon?: string
title?: string
description?: string
}>(),
{ icon: 'inbox' },
)
</script>
<template>
<div class="cc-empty">
<div class="cc-empty__icon">
<Icon :name="icon" :size="22" :stroke-width="1.75" />
</div>
<div v-if="title" class="cc-empty__title">{{ title }}</div>
<div v-if="description" class="cc-empty__desc">{{ description }}</div>
<div v-if="$slots.action" class="cc-empty__action"><slot name="action" /></div>
</div>
</template>
<style>
.cc-empty { display:flex; flex-direction:column; align-items:center; text-align:center; gap:5px; padding:36px 24px; }
.cc-empty__icon { width:46px; height:46px; border-radius:var(--radius-lg); display:flex; align-items:center; justify-content:center; margin-bottom:8px; color:var(--text-tertiary); background:var(--surface-raised-2); box-shadow:var(--ring-default); }
.cc-empty__title { font-size:var(--text-base); font-weight:600; color:var(--text-primary); }
.cc-empty__desc { font-size:var(--text-sm); color:var(--text-tertiary); max-width:340px; line-height:1.5; }
.cc-empty__action { margin-top:12px; }
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/**
* Checkbox — square toggle; checked/indeterminate state carries the live game accent.
* v-model binds to boolean checked state via defineModel<boolean>().
* The hidden <input type="checkbox"> drives CSS :checked/:indeterminate/:focus-visible/:disabled.
*/
import Icon from '@/components/ds/core/Icon.vue'
const props = withDefaults(
defineProps<{
label?: string
disabled?: boolean
id?: string
}>(),
{ disabled: false },
)
const model = defineModel<boolean>()
</script>
<template>
<label class="cc-check" :for="id">
<input
type="checkbox"
:id="id"
:checked="model"
:disabled="disabled"
@change="model = ($event.target as HTMLInputElement).checked"
/>
<span class="cc-check__box">
<Icon name="check" :size="12" :stroke-width="3" />
</span>
<span v-if="label" class="cc-check__label">{{ label }}</span>
</label>
</template>
<style>
.cc-check { display:inline-flex; align-items:center; gap:9px; cursor:pointer; user-select:none; }
.cc-check input { position:absolute; opacity:0; width:0; height:0; }
.cc-check__box {
width:17px; height:17px; flex:none; border-radius:var(--radius-xs); background:var(--surface-inset);
box-shadow: inset 0 0 0 1px var(--border-strong); display:flex; align-items:center; justify-content:center;
color:transparent; transition:var(--transition-colors);
}
.cc-check input:checked + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
.cc-check input:indeterminate + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
.cc-check input:focus-visible + .cc-check__box { box-shadow: var(--focus-ring); }
.cc-check input:disabled + .cc-check__box { opacity:.5; }
.cc-check__label { font-size:var(--text-sm); color:var(--text-primary); }
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
/**
* Input — text field with label, hint/error, leading icon and affixes.
* Reach for `mono` on any technical value (ports, tokens, IDs).
* v-model binds to the inner <input> value via defineModel<string>().
*/
import Icon from '@/components/ds/core/Icon.vue'
const props = withDefaults(
defineProps<{
label?: string
hint?: string
error?: string
icon?: string
prefix?: string
suffix?: string
size?: 'md' | 'sm'
mono?: boolean
required?: boolean
id?: string
placeholder?: string
disabled?: boolean
type?: string
}>(),
{ size: 'md', mono: false, required: false, disabled: false, type: 'text' },
)
const model = defineModel<string>()
const invalid = () => !!props.error
</script>
<template>
<label
class="cc-field"
:for="id"
>
<span v-if="label" class="cc-field__label">
{{ label }}<span v-if="required" class="req">*</span>
</span>
<span
:class="[
'cc-input',
size === 'sm' && 'cc-input--sm',
mono && 'cc-input--mono',
invalid() && 'cc-input--invalid',
]"
>
<Icon v-if="icon" :name="icon" :size="15" />
<span v-if="prefix" class="cc-input__affix">{{ prefix }}</span>
<input
:id="id"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="model"
@input="model = ($event.target as HTMLInputElement).value"
/>
<span v-if="suffix" class="cc-input__affix">{{ suffix }}</span>
</span>
<span
v-if="hint || error"
:class="['cc-field__hint', invalid() && 'cc-field__hint--error']"
>{{ error ?? hint }}</span>
</label>
</template>
<style>
.cc-field { display:flex; flex-direction:column; gap:6px; }
.cc-field__label { font-size:var(--text-xs); font-weight:600; color:var(--text-secondary); }
.cc-field__label .req { color:var(--accent-text); margin-left:2px; }
.cc-input {
display:flex; align-items:center; gap:8px; height:var(--control-h-md); padding:0 11px;
background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default);
transition:var(--transition-colors); color:var(--text-tertiary);
}
.cc-input:focus-within { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
.cc-input--sm { height:var(--control-h-sm); padding:0 9px; }
.cc-input--invalid { box-shadow: inset 0 0 0 1px var(--status-offline-border); }
.cc-input--invalid:focus-within { box-shadow: inset 0 0 0 1px var(--danger); }
.cc-input input {
flex:1; min-width:0; background:transparent; border:0; outline:0; padding:0; margin:0;
font-family:var(--font-sans); font-size:var(--text-sm); color:var(--text-primary);
}
.cc-input input::placeholder { color:var(--text-muted); }
.cc-input--mono input { font-family:var(--font-mono); }
.cc-input__affix { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-muted); white-space:nowrap; }
.cc-field__hint { font-size:var(--text-xs); color:var(--text-tertiary); }
.cc-field__hint--error { color:var(--danger); }
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* Select — styled native <select> with chevron overlay.
* With `label` the root becomes a <label> wrapping the control.
* v-model binds to the selected value via defineModel<string>().
*/
import Icon from '@/components/ds/core/Icon.vue'
type SelectOption = string | { value: string; label: string }
const props = withDefaults(
defineProps<{
label?: string
options?: SelectOption[]
size?: 'md' | 'sm'
id?: string
disabled?: boolean
}>(),
{ options: () => [], size: 'md', disabled: false },
)
const model = defineModel<string>()
function optionValue(o: SelectOption): string {
return typeof o === 'string' ? o : o.value
}
function optionLabel(o: SelectOption): string {
return typeof o === 'string' ? o : o.label
}
</script>
<template>
<!-- With label: wrap in <label> matching React's cc-field layout -->
<label v-if="label" class="cc-field" :for="id">
<span class="cc-field__label">{{ label }}</span>
<span :class="['cc-select', size === 'sm' && 'cc-select--sm']">
<select
:id="id"
:disabled="disabled"
:value="model"
@change="model = ($event.target as HTMLSelectElement).value"
>
<option
v-for="(o, i) in options"
:key="i"
:value="optionValue(o)"
>{{ optionLabel(o) }}</option>
</select>
<span class="cc-select__chev">
<Icon name="chevron-down" :size="15" />
</span>
</span>
</label>
<!-- Without label: bare control -->
<span v-else :class="['cc-select', size === 'sm' && 'cc-select--sm']">
<select
:id="id"
:disabled="disabled"
:value="model"
@change="model = ($event.target as HTMLSelectElement).value"
>
<option
v-for="(o, i) in options"
:key="i"
:value="optionValue(o)"
>{{ optionLabel(o) }}</option>
</select>
<span class="cc-select__chev">
<Icon name="chevron-down" :size="15" />
</span>
</span>
</template>
<style>
.cc-select { position:relative; display:flex; align-items:center; }
.cc-select select {
appearance:none; width:100%; height:var(--control-h-md); padding:0 32px 0 11px;
background:var(--surface-inset); color:var(--text-primary); border:0; border-radius:var(--radius-md);
box-shadow:var(--ring-default); font-family:var(--font-sans); font-size:var(--text-sm); cursor:pointer;
transition:var(--transition-colors); outline:0;
}
.cc-select select:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
.cc-select--sm select { height:var(--control-h-sm); padding:0 28px 0 9px; }
.cc-select__chev { position:absolute; right:9px; pointer-events:none; color:var(--text-tertiary); display:flex; }
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* Switch — toggle control; checked state carries the live game accent.
* v-model binds to boolean checked state via defineModel<boolean>().
* The hidden <input type="checkbox"> drives CSS :checked/:focus-visible/:disabled.
*/
const props = withDefaults(
defineProps<{
label?: string
size?: 'md' | 'sm'
disabled?: boolean
id?: string
}>(),
{ size: 'md', disabled: false },
)
const model = defineModel<boolean>()
</script>
<template>
<label
:class="['cc-switch', size === 'sm' && 'cc-switch--sm']"
:for="id"
>
<input
type="checkbox"
:id="id"
:checked="model"
:disabled="disabled"
@change="model = ($event.target as HTMLInputElement).checked"
/>
<span class="cc-switch__track">
<span class="cc-switch__thumb" />
</span>
<span v-if="label" class="cc-switch__label">{{ label }}</span>
</label>
</template>
<style>
.cc-switch { display:inline-flex; align-items:center; gap:10px; cursor:pointer; user-select:none; }
.cc-switch input { position:absolute; opacity:0; width:0; height:0; }
.cc-switch__track {
position:relative; width:36px; height:20px; border-radius:var(--radius-pill); flex:none;
background:var(--surface-active); box-shadow: inset 0 0 0 1px var(--border-default);
transition: background var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
}
.cc-switch__thumb {
position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%;
background:var(--text-secondary); transition: transform var(--dur-base) var(--ease-emphasized), background var(--dur-base);
}
.cc-switch input:checked + .cc-switch__track { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
.cc-switch input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(16px); background:var(--accent-contrast); }
.cc-switch input:focus-visible + .cc-switch__track { box-shadow: var(--focus-ring); }
.cc-switch input:disabled + .cc-switch__track { opacity:.5; }
.cc-switch__label { font-size:var(--text-sm); color:var(--text-primary); }
.cc-switch--sm .cc-switch__track { width:30px; height:17px; }
.cc-switch--sm .cc-switch__thumb { width:13px; height:13px; }
.cc-switch--sm input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(13px); }
</style>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* GameSwitcher — segmented control for switching the active game context.
* Set `data-game` on a root shell element to the chosen key so the global
* [data-game] CSS custom properties re-skin the entire panel.
* Per-game accent comes from var(--accent) which is resolved by the [data-game] token scope.
*/
import Icon from '@/components/ds/core/Icon.vue'
export interface GameOption {
key: string
label: string
icon?: string
}
const props = withDefaults(
defineProps<{
games: (string | GameOption)[]
showLabels?: boolean
class?: string
}>(),
{ showLabels: true },
)
const model = defineModel<string>({ required: true })
function normalise(g: string | GameOption): GameOption {
return typeof g === 'string' ? { key: g, label: g } : g
}
</script>
<template>
<div :class="['cc-gameswitch', props.class]" role="group">
<button
v-for="raw in games"
:key="normalise(raw).key"
type="button"
:aria-pressed="normalise(raw).key === model"
:data-game="normalise(raw).key"
class="cc-gameswitch__opt"
:title="normalise(raw).label"
@click="model = normalise(raw).key"
>
<Icon
v-if="normalise(raw).icon"
:name="normalise(raw).icon ?? ''"
:size="14"
:stroke-width="2.25"
:style="{ color: normalise(raw).key === model ? 'var(--accent)' : 'inherit' }"
/>
<span
v-else
class="cc-gameswitch__dot"
:style="{ background: normalise(raw).key === model ? 'var(--accent)' : 'var(--text-muted)' }"
/>
<span v-if="showLabels">{{ normalise(raw).label }}</span>
</button>
</div>
</template>
<style>
.cc-gameswitch { display:inline-flex; align-items:center; gap:3px; padding:3px; background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default); }
.cc-gameswitch__opt {
display:inline-flex; align-items:center; gap:7px; height:28px; padding:0 11px; border:0; background:transparent;
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:600; color:var(--text-tertiary);
border-radius:var(--radius-sm); cursor:pointer; transition:var(--transition-colors); white-space:nowrap;
}
.cc-gameswitch__opt:hover { color:var(--text-primary); }
.cc-gameswitch__dot { width:8px; height:8px; border-radius:50%; background:var(--accent); flex:none; }
.cc-gameswitch__opt[aria-pressed="true"] { background:var(--surface-raised-2); color:var(--text-primary); box-shadow:var(--ring-default); }
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/**
* NavItem — sidebar navigation row: icon + label, active state with accent rail,
* optional trailing count. Collapsed mode renders icon-only at 40 px wide.
*/
import Icon from '@/components/ds/core/Icon.vue'
withDefaults(
defineProps<{
icon: string
label: string
active?: boolean
count?: number | string
collapsed?: boolean
}>(),
{ active: false, collapsed: false },
)
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<div
:class="['cc-nav', active && 'cc-nav--active', collapsed && 'cc-nav--collapsed']"
role="button"
:aria-current="active ? 'page' : undefined"
:title="collapsed ? label : undefined"
@click="emit('click')"
>
<span class="cc-nav__icon">
<Icon :name="icon" :size="17" :stroke-width="2" />
</span>
<span v-if="!collapsed" class="cc-nav__label">{{ label }}</span>
<span v-if="!collapsed && count != null" class="cc-nav__count">{{ count }}</span>
</div>
</template>
<style>
.cc-nav { display:flex; align-items:center; gap:10px; height:34px; padding:0 10px; border-radius:var(--radius-md);
color:var(--text-secondary); cursor:pointer; transition:var(--transition-colors); position:relative; user-select:none; }
.cc-nav:hover { background:var(--surface-hover); color:var(--text-primary); }
.cc-nav__icon { flex:none; color:var(--text-tertiary); display:flex; transition:var(--transition-colors); }
.cc-nav__label { flex:1; font-size:var(--text-sm); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cc-nav__count { font-family:var(--font-mono); font-size:11px; color:var(--text-tertiary); padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); }
.cc-nav--active { background:var(--accent-soft); color:var(--accent-text); }
.cc-nav--active .cc-nav__icon { color:var(--accent-text); }
.cc-nav--active::before { content:''; position:absolute; left:-10px; top:7px; bottom:7px; width:3px; border-radius:var(--radius-pill); background:var(--accent); }
.cc-nav--active .cc-nav__count { background:var(--accent-soft-strong); color:var(--accent-text); }
.cc-nav--collapsed { justify-content:center; padding:0; width:40px; }
</style>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
/**
* Tabs — horizontal tab bar. variant="pill" fills active tab with accent-soft;
* variant="line" underlines with accent. Items can be bare strings or TabItem objects.
*/
import Icon from '@/components/ds/core/Icon.vue'
export interface TabItem {
value: string
label: string
icon?: string
count?: number | string
}
const props = withDefaults(
defineProps<{
items: (string | TabItem)[]
variant?: 'pill' | 'line'
class?: string
}>(),
{ variant: 'pill' },
)
const model = defineModel<string>({ required: true })
function normalise(it: string | TabItem): TabItem {
return typeof it === 'string' ? { value: it, label: it } : it
}
</script>
<template>
<div
:class="['cc-tabs', `cc-tabs--${variant}`, props.class]"
role="tablist"
>
<button
v-for="raw in items"
:key="normalise(raw).value"
type="button"
role="tab"
:aria-selected="normalise(raw).value === model"
class="cc-tab"
@click="model = normalise(raw).value"
>
<Icon v-if="normalise(raw).icon" :name="normalise(raw).icon ?? ''" :size="15" />
{{ normalise(raw).label }}
<span v-if="normalise(raw).count != null" class="cc-tab__count">{{ normalise(raw).count }}</span>
</button>
</div>
</template>
<style>
.cc-tabs { display:flex; align-items:center; gap:2px; position:relative; }
.cc-tabs--line { box-shadow: inset 0 -1px 0 var(--border-subtle); gap:4px; }
.cc-tab {
display:inline-flex; align-items:center; gap:7px; height:32px; padding:0 11px; border:0; background:transparent;
font-family:var(--font-sans); font-size:var(--text-sm); font-weight:500; color:var(--text-tertiary);
cursor:pointer; border-radius:var(--radius-sm); transition:var(--transition-colors); white-space:nowrap; position:relative;
}
.cc-tab:hover { color:var(--text-primary); background:var(--surface-hover); }
.cc-tabs--pill .cc-tab[aria-selected="true"] { color:var(--accent-text); background:var(--accent-soft); }
.cc-tabs--line .cc-tab { border-radius:0; height:38px; padding:0 4px; margin:0 7px; }
.cc-tabs--line .cc-tab:hover { background:transparent; }
.cc-tabs--line .cc-tab[aria-selected="true"] { color:var(--text-primary); box-shadow: inset 0 -2px 0 var(--accent); }
.cc-tab__count { font-family:var(--font-mono); font-size:11px; padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); color:var(--text-tertiary); }
.cc-tab[aria-selected="true"] .cc-tab__count { background:var(--accent-soft); color:var(--accent-text); }
</style>

View File

@@ -1,103 +1,120 @@
<script setup lang="ts">
/**
* DashboardLayout — game-aware app shell (Phase C redesign).
* Replaces the old Tailwind-only sidebar with the DS component set.
* Preserves: navSections, permission gating, super-admin section, logout, RouterView.
* Adds: GameSwitcher, Logo, DS NavItem, agent-health footer, topbar w/ search + theme toggle.
*/
import { ref } from 'vue'
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
import {
LayoutDashboard,
Server,
Terminal,
Users,
Puzzle,
RefreshCw,
Map,
MessageSquare,
BarChart3,
Bell,
UserPlus,
ShoppingBag,
Package,
Settings,
LogOut,
Shield,
Key,
CreditCard,
Network,
Clock,
AlertTriangle,
FileText,
FolderOpen,
Menu,
X,
} from 'lucide-vue-next'
import { useThemeGame } from '@/composables/useThemeGame'
import Logo from '@/components/ds/brand/Logo.vue'
import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Button from '@/components/ds/core/Button.vue'
import Avatar from '@/components/ds/data/Avatar.vue'
import NavItem from '@/components/ds/navigation/NavItem.vue'
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
import type { ActiveGame } from '@/composables/useThemeGame'
// ---- Stores / composables ----
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const server = useServerStore()
const sidebarOpen = ref(false)
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
type NavItem = { name: string; path: string; icon: any; permission: string | null }
type NavSection = { label: string; items: NavItem[] }
// ---- Mobile sidebar ----
const sidebarOpen = ref(false)
function closeSidebar() { sidebarOpen.value = false }
// ---- App version ----
const APP_VERSION = '1.0.8'
// ---- Game switcher ----
const GAME_OPTIONS: GameOption[] = [
{ key: 'all', label: 'All games', icon: 'layers' },
{ key: 'rust', label: 'Rust', icon: 'box' },
{ key: 'dune', label: 'Dune', icon: 'sun' },
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
]
const GAME_LABEL: Record<string, string> = {
all: 'All games', rust: 'Rust', dune: 'Dune',
conan: 'Conan Exiles', soulmask: 'Soulmask',
}
function onActiveGame(val: string) {
setActiveGame(val as ActiveGame)
}
// ---- Navigation ----
type NavItemDef = { name: string; path: string; icon: string; permission: string | null }
type NavSection = { label: string; items: NavItemDef[] }
const navSections: NavSection[] = [
{
label: '',
items: [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', permission: null },
],
},
{
label: 'Server',
items: [
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
{ name: 'Server', path: '/server', icon: 'server', permission: 'server.view' },
{ name: 'Console', path: '/console', icon: 'terminal', permission: 'console.view' },
{ name: 'Players', path: '/players', icon: 'users', permission: 'players.view' },
{ name: 'Plugins', path: '/plugins', icon: 'puzzle', permission: 'plugins.view' },
{ name: 'File manager', path: '/files', icon: 'folder-open', permission: 'files.view' },
],
},
{
label: 'Plugin Configs',
label: 'Plugin configs',
items: [
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
],
},
{
label: 'Operations',
items: [
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
],
},
{
label: 'Monitoring',
items: [
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
],
},
{
label: 'Management',
items: [
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
{ name: 'Team', path: '/team', icon: 'users', permission: null },
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
],
},
]
const adminNavItems = [
{ name: 'Admin Home', path: '/admin', icon: Shield },
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
{ name: 'Users', path: '/admin/users', icon: Users },
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
{ name: 'Admin home', path: '/admin', icon: 'shield' },
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
{ name: 'Users', path: '/admin/users', icon: 'users' },
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
]
function isActive(path: string): boolean {
@@ -105,16 +122,12 @@ function isActive(path: string): boolean {
return route.path.startsWith(path)
}
function handleLogout() {
auth.logout()
router.push('/login')
function navigate(path: string) {
router.push(path)
closeSidebar()
}
function closeSidebar() {
sidebarOpen.value = false
}
function canShowNavItem(item: NavItem): boolean {
function canShowNavItem(item: NavItemDef): boolean {
if (!item.permission) return true
return auth.hasPermission(item.permission)
}
@@ -122,134 +135,486 @@ function canShowNavItem(item: NavItem): boolean {
function hasVisibleItems(section: NavSection): boolean {
return section.items.some(canShowNavItem)
}
// ---- Agent health ----
const agentTone = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online' as const
if (cs === 'degraded') return 'warn' as const
return 'offline' as const
})
const agentLabel = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'Healthy'
if (cs === 'degraded') return 'Degraded'
return 'Offline'
})
const agentName = computed(() => {
const ip = server.connection?.server_ip
return ip ?? 'asgard-01'
})
// ---- Topbar ----
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
const userName = computed(() => auth.user?.username ?? '')
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
// ---- Import computed from vue (missed above) ----
import { computed } from 'vue'
</script>
<template>
<div class="flex h-screen bg-neutral-950">
<!-- Mobile Hamburger -->
<button
@click="sidebarOpen = true"
class="md:hidden fixed top-4 left-4 z-40 p-2 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-300 hover:text-oxide-400 transition-colors"
>
<Menu class="w-5 h-5" />
</button>
<!-- Sidebar Overlay (Mobile) -->
<!-- Outer app grid: sidebar | main -->
<div class="app">
<!-- ===================================================== SIDEBAR ===== -->
<!-- Mobile overlay -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="closeSidebar"
class="md:hidden fixed inset-0 bg-black/50 z-40"
/>
<!-- Sidebar -->
<aside
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
class="app__sidebar"
:class="sidebarOpen ? 'app__sidebar--open' : ''"
>
<!-- Logo -->
<div class="p-4 border-b border-neutral-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
<div>
<h1 class="text-sm font-bold text-oxide-500 tracking-wider">CORROSION</h1>
<p class="text-xs text-neutral-500">{{ auth.license?.server_name || 'Server Management' }}</p>
</div>
</div>
<button
@click="closeSidebar"
class="md:hidden text-neutral-400 hover:text-neutral-200 transition-colors"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Brand -->
<div class="side__brand">
<Logo :size="22" />
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
</div>
<!-- Server Status Indicator -->
<div class="px-4 py-3 border-b border-neutral-800">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
:class="{
'bg-green-500': server.connection?.connection_status === 'connected',
'bg-yellow-500': server.connection?.connection_status === 'degraded',
'bg-red-500': server.connection?.connection_status === 'offline' || !server.connection,
}"
/>
<span class="text-sm text-neutral-400">
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? 0 }} players
</span>
<!-- Active game switcher -->
<div class="side__game">
<div class="t-eyebrow side__lbl">
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
</div>
<GameSwitcher
:model-value="activeGame"
:games="GAME_OPTIONS"
:show-labels="false"
@update:model-value="onActiveGame"
/>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto py-2">
<nav class="side__nav">
<template v-for="section in navSections" :key="section.label">
<template v-if="hasVisibleItems(section)">
<!-- Section Header -->
<div v-if="section.label" class="mt-4 mb-1 px-4">
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
<div class="side__sec">
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
<NavItem
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.path"
:icon="item.icon"
:label="item.name"
:active="isActive(item.path)"
@click="navigate(item.path)"
/>
</div>
<!-- Section Items -->
<RouterLink
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.path"
:to="item.path"
@click="closeSidebar"
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
:class="isActive(item.path)
? 'bg-oxide-500/10 text-oxide-400'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
>
<component :is="item.icon" class="w-4 h-4" />
{{ item.name }}
</RouterLink>
</template>
</template>
<!-- Platform Admin Section (super-admin only) -->
<template v-if="auth.isSuperAdmin">
<div class="mt-4 mb-1 px-4">
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
</div>
<RouterLink
<!-- Platform admin section (super-admin only) -->
<div v-if="auth.isSuperAdmin" class="side__sec">
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
<NavItem
v-for="item in adminNavItems"
:key="item.path"
:to="item.path"
@click="closeSidebar"
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
:class="isActive(item.path)
? 'bg-oxide-500/10 text-oxide-400'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
>
<component :is="item.icon" class="w-4 h-4" />
{{ item.name }}
</RouterLink>
</template>
:icon="item.icon"
:label="item.name"
:active="isActive(item.path)"
@click="navigate(item.path)"
/>
</div>
</nav>
<!-- User -->
<div class="p-4 border-t border-neutral-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-neutral-300">{{ auth.user?.username }}</p>
<p class="text-xs text-neutral-500">{{ auth.user?.email }}</p>
<!-- Agent health footer -->
<div class="side__foot">
<div class="agent">
<div class="agent__row">
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
<span class="agent__name">{{ agentName }}</span>
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
</div>
<div class="agent__meta">
Agent v{{ APP_VERSION }}
<template v-if="server.stats"> · {{ server.stats.player_count }}/{{ server.stats.max_players }} players</template>
</div>
</div>
<!-- User / logout row -->
<div class="side__user">
<span class="side__user-name">{{ auth.user?.username ?? '' }}</span>
<button
@click="handleLogout"
class="text-neutral-500 hover:text-oxide-400 transition-colors"
type="button"
class="side__logout"
title="Sign out"
@click="() => { auth.logout(); router.push('/login') }"
>
<LogOut class="w-4 h-4" />
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
</aside>
<!-- Main Content (offset by sidebar width on desktop) -->
<main class="flex-1 overflow-y-auto md:pl-64">
<RouterView />
</main>
<!-- ======================================================= MAIN ===== -->
<div class="app__main">
<!-- Topbar -->
<header class="app__topbar">
<!-- Mobile hamburger (left of topbar on small screens) -->
<button
class="topbar-hamburger"
type="button"
aria-label="Open navigation"
@click="sidebarOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<!-- Breadcrumb -->
<div class="top__crumbs">
<span class="crumb">Corrosion</span>
<span class="crumb__sep">/</span>
<span class="crumb crumb--cluster">{{ serverName }}</span>
</div>
<!-- Search -->
<div class="top__search">
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input placeholder="Search servers, players, configs…" readonly />
<span class="top__kbd">
<kbd class="cc-kbd"></kbd><kbd class="cc-kbd">K</kbd>
</span>
</div>
<!-- Actions -->
<div class="top__actions">
<IconButton
:icon="themeIcon"
label="Toggle theme"
@click="toggleTheme"
/>
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
<Button size="sm" icon="rocket">Deploy server</Button>
<Avatar
:name="userName"
:size="30"
status="online"
/>
</div>
</header>
<!-- Page content -->
<main class="app__content">
<RouterView />
</main>
</div>
</div>
</template>
<style>
/* ============================================================ SHELL ===== */
html, body { height: 100%; }
body { margin: 0; overflow: hidden; }
.app {
display: grid;
grid-template-columns: var(--sidebar-w, 228px) 1fr;
height: 100vh;
background: var(--surface-canvas, #0a0a0b);
}
/* ---- Sidebar ---- */
.app__sidebar {
background: var(--surface-base);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
z-index: 50;
}
.side__brand {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 16px 12px;
}
.side__ver {
font-family: var(--font-mono);
}
.side__game {
padding: 2px 14px 13px;
border-bottom: 1px solid var(--border-subtle);
}
.side__lbl {
margin-bottom: 7px;
}
.side__lbl--platform {
color: var(--accent-text);
}
.side__nav {
flex: 1;
overflow-y: auto;
padding: 13px 10px;
display: flex;
flex-direction: column;
gap: 15px;
}
.side__sec {
display: flex;
flex-direction: column;
gap: 2px;
}
.side__sec .t-eyebrow {
margin: 0 0 5px 10px;
}
.side__foot {
padding: 11px;
border-top: 1px solid var(--border-subtle);
}
.agent {
background: var(--surface-raised);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
padding: 9px 11px;
}
.agent__row {
display: flex;
align-items: center;
gap: 8px;
}
.agent__name {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-primary);
flex: 1;
}
.agent__meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-top: 5px;
padding-left: 16px;
}
.side__user {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 4px 2px 0;
}
.side__user-name {
font-size: var(--text-xs);
color: var(--text-tertiary);
font-family: var(--font-mono);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side__logout {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-colors);
flex: none;
}
.side__logout:hover { background: var(--surface-hover); color: var(--text-primary); }
/* ---- Main ---- */
.app__main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.app__topbar {
height: var(--topbar-h, 52px);
flex: none;
display: flex;
align-items: center;
gap: 18px;
padding: 0 18px;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-base);
}
.top__crumbs {
display: flex;
align-items: center;
gap: 9px;
font-size: var(--text-sm);
}
.crumb { color: var(--text-tertiary); }
.crumb__sep { color: var(--text-muted); }
.crumb--cluster {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
font-weight: 600;
background: var(--surface-raised-2);
border: 0;
box-shadow: var(--ring-default);
height: 30px;
padding: 0 10px;
border-radius: var(--radius-md);
font-family: var(--font-sans);
}
.top__search {
flex: 1;
max-width: 440px;
display: flex;
align-items: center;
gap: 9px;
height: 34px;
padding: 0 11px;
background: var(--surface-inset);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--text-tertiary);
}
.top__search-icon { flex: none; }
.top__search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-sm);
cursor: default;
}
.top__search input::placeholder { color: var(--text-muted); }
.top__kbd { display: flex; gap: 3px; }
.top__actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.app__content {
flex: 1;
overflow-y: auto;
padding: 22px 24px 40px;
}
/* ---- Mobile hamburger ---- */
.topbar-hamburger {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
flex: none;
}
.topbar-hamburger:hover { background: var(--surface-hover); color: var(--text-primary); }
/* ---- Sidebar overlay (mobile) ---- */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
}
/* ---- Kbd styling ---- */
.cc-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--surface-active);
border-radius: var(--radius-xs, 3px);
box-shadow: var(--ring-default);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1;
}
/* ---- Responsive ---- */
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
}
.app__sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 228px;
transform: translateX(-100%);
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
}
.app__sidebar--open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.topbar-hamburger {
display: inline-flex;
}
.top__search {
display: none;
}
}
</style>

View File

@@ -0,0 +1,114 @@
/**
* useThemeGame — the Corrosion design-system theming contract.
*
* Drives `data-theme` and `data-game` on <html>, the two attributes the token
* system keys off (see styles/tokens/colors.css + game-themes.css):
* <html data-theme="dark|light" data-game="rust|dune|conan|soulmask|...">
*
* Dark is primary; Rust (Oxide Orange) is the default/brand accent.
*
* Runtime swaps add the `cc-skin-swap` class for one frame so every
* accent-consuming surface repaints immediately — without it Chrome leaves
* elements that read var(--accent) AND have a color/bg transition on the old
* accent until the next reflow (see styles/tokens/base.css + readme).
*/
import { ref, readonly } from 'vue'
export type Theme = 'dark' | 'light'
export type Game =
| 'rust'
| 'dune'
| 'conan'
| 'soulmask'
| 'ark'
| 'valheim'
| 'palworld'
/** The fleet filter: 'all' (every game) plus each individual game. */
export type ActiveGame = 'all' | Game
const THEME_KEY = 'cc-theme'
const GAME_KEY = 'cc-game'
const ACTIVE_GAME_KEY = 'cc-active-game'
const VALID_THEMES: readonly Theme[] = ['dark', 'light']
const VALID_GAMES: readonly Game[] = [
'rust',
'dune',
'conan',
'soulmask',
'ark',
'valheim',
'palworld',
]
// Module-scope singletons so every caller shares one reactive source.
const theme = ref<Theme>('dark')
const game = ref<Game>('rust')
// Fleet filter: 'all' shows every game and uses the neutral house skin (Oxide);
// a specific game both filters the fleet AND re-skins the shell (the drill-in rule).
const activeGame = ref<ActiveGame>('all')
function apply(): void {
const el = document.documentElement
el.classList.add('cc-skin-swap')
el.setAttribute('data-theme', theme.value)
el.setAttribute('data-game', game.value)
// Keep Tailwind's `dark` class in sync — existing views may use `dark:` utilities.
el.classList.toggle('dark', theme.value === 'dark')
// Drop the swap guard after the paint that picked up the new accent.
requestAnimationFrame(() =>
requestAnimationFrame(() => el.classList.remove('cc-skin-swap')),
)
}
let initialized = false
/**
* Read persisted prefs and apply them to <html>. Call once at app start
* (after a tiny inline FOUC guard in index.html has set the initial attrs).
*/
export function initThemeGame(): void {
if (initialized) return
const t = localStorage.getItem(THEME_KEY)
if (t && (VALID_THEMES as string[]).includes(t)) theme.value = t as Theme
const ag = localStorage.getItem(ACTIVE_GAME_KEY)
if (ag && (ag === 'all' || (VALID_GAMES as string[]).includes(ag))) {
activeGame.value = ag as ActiveGame
}
// Skin follows the filter: 'all' -> neutral house (rust/oxide), else the game.
game.value = activeGame.value === 'all' ? 'rust' : activeGame.value
apply()
initialized = true
}
export function useThemeGame() {
function setTheme(t: Theme): void {
theme.value = t
localStorage.setItem(THEME_KEY, t)
apply()
}
function setGame(g: Game): void {
game.value = g
localStorage.setItem(GAME_KEY, g)
apply()
}
function setActiveGame(g: ActiveGame): void {
activeGame.value = g
localStorage.setItem(ACTIVE_GAME_KEY, g)
// 'all' uses the neutral house skin (rust/oxide); a game re-skins to itself.
setGame(g === 'all' ? 'rust' : g)
}
function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
return {
theme: readonly(theme),
game: readonly(game),
activeGame: readonly(activeGame),
setTheme,
setGame,
setActiveGame,
toggleTheme,
}
}

View File

@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { VueFinderPlugin } from 'vuefinder'
import App from './App.vue'
import router from './router'
import { initThemeGame } from './composables/useThemeGame'
import './style.css'
import 'vuefinder/dist/vuefinder.css'
@@ -17,4 +18,7 @@ app.use(pinia)
app.use(router)
app.use(VueFinderPlugin)
// Apply the design-system theming contract (data-theme/data-game on <html>).
initThemeGame()
app.mount('#app')

View File

@@ -1,6 +1,10 @@
@import "tailwindcss";
@import "./styles/corrosion.css";
/* Corrosion Brand — Oxide Orange #F26622 */
/* Tailwind utility colors — Oxide ramp (existing views use bg-oxide-*).
The full design-token system (neutral ramp, surfaces, per-game accents,
typography, spacing, elevation, motion) lives in ./styles/ and is the
source of truth for the redesign. */
@theme {
--color-oxide-50: #FEF3EB;
--color-oxide-100: #FDE3D0;
@@ -15,7 +19,8 @@
--color-oxide-950: #3D1506;
}
/* Dark mode is default — Rust servers run at night */
/* Legacy brand vars — retained during the redesign port so any view still
referencing them keeps working; superseded by the ./styles tokens. */
:root {
--corrosion-accent: #F26622;
--corrosion-dark: #000000;
@@ -24,12 +29,8 @@
--corrosion-border: #2a2a2a;
}
body {
@apply bg-neutral-950 text-neutral-100 antialiased;
margin: 0;
min-height: 100vh;
}
/* Body background / text / font now come from the design system
(./styles/tokens/base.css → var(--surface-canvas), var(--text-primary)). */
#app {
min-height: 100vh;
}

View File

@@ -0,0 +1,15 @@
/* ============================================================
Corrosion Control — Design System
Root stylesheet. Consumers link THIS one file.
Import order matters: fonts → primitives → colors → game themes
→ base. (base.css references color/accent tokens.)
============================================================ */
@import url("tokens/fonts.css");
@import url("tokens/spacing.css");
@import url("tokens/typography.css");
@import url("tokens/motion.css");
@import url("tokens/colors.css");
@import url("tokens/game-themes.css");
@import url("tokens/elevation.css");
@import url("tokens/base.css");

View File

@@ -0,0 +1,75 @@
/* ============================================================
Corrosion Control — Base / Reset
Minimal, opinionated. Applies the token system to bare HTML so
specimen cards and kits inherit the look without boilerplate.
============================================================ */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
background-color: var(--surface-canvas);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "cv01", "ss01";
}
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; }
a { color: inherit; text-decoration: none; }
::selection {
background: var(--accent-soft-strong);
color: var(--text-primary);
}
/* Tabular numbers everywhere numbers matter */
.tnum { font-variant-numeric: tabular-nums; }
/* Custom scrollbars — quiet, on-brand */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-pill);
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: content-box; }
/* Focus-visible default */
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Reduced motion guard */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ---- Skin-swap repaint guard ----
When flipping data-game or data-theme on the root, add the .cc-skin-swap
class for ONE frame. This suppresses transitions during the swap so every
accent-consuming surface repaints immediately — works around a Chrome
custom-property + transition staleness where elements that both read
var(--accent)/var(--accent-text) AND have a color/background transition keep
the old accent until the next reflow. See readme "Theming contract". */
.cc-skin-swap *,
.cc-skin-swap *::before,
.cc-skin-swap *::after { transition: none !important; }

View File

@@ -0,0 +1,136 @@
/* ============================================================
Corrosion Control — Color System
------------------------------------------------------------
Three layers:
1. Raw neutral ramp (absolute; identical in both themes)
2. Semantic surface / text / border tokens (flip per theme)
3. Status colors (online/offline/warn/info/...) — game-agnostic
Game ACCENT colors live in game-themes.css.
Theme + game are set together on <html>:
<html data-theme="dark" data-game="rust">
Dark is primary. Light is full-parity.
============================================================ */
:root {
/* ---- Raw neutral ramp (warm-cool slate, absolute) ---- */
--n-0: #ffffff;
--n-25: #fafbfc;
--n-50: #f3f5f7;
--n-100: #e8ebef;
--n-150: #dce0e6;
--n-200: #ccd2da;
--n-300: #aeb6c1;
--n-400: #8a929e;
--n-500: #6b7280;
--n-600: #515862;
--n-650: #444a53;
--n-700: #363b43;
--n-750: #2b2f36;
--n-800: #1f2329;
--n-850: #181b20;
--n-900: #121419;
--n-925: #0e0f13;
--n-950: #0a0b0e;
--n-975: #060709;
/* ---- Semantic: DARK (default) ---- */
--surface-canvas: var(--n-950); /* app background */
--surface-sunken: var(--n-975); /* wells, deep insets */
--surface-base: var(--n-925); /* primary panels */
--surface-raised: var(--n-850); /* cards, rows */
--surface-raised-2:var(--n-800); /* nested cards, hover cards */
--surface-overlay: var(--n-800); /* menus, popovers, dialogs */
--surface-inset: #07080a; /* inputs, console, code */
--surface-hover: rgba(255, 255, 255, 0.045);
--surface-active: rgba(255, 255, 255, 0.075);
--surface-selected:rgba(255, 255, 255, 0.06);
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.16);
--text-primary: #f2f4f7;
--text-secondary: #aeb4bf;
--text-tertiary: #767d89;
--text-muted: #565d68;
--text-inverse: #0a0b0e;
--text-on-accent: var(--accent-contrast);
/* Scrim for modals / image overlays */
--scrim: rgba(4, 5, 7, 0.66);
/* ---- Status / semantic (consistent across themes) ---- */
--status-online: #36c780;
--status-online-soft: rgba(54, 199, 128, 0.14);
--status-online-border: rgba(54, 199, 128, 0.38);
--status-offline: #e5484d;
--status-offline-soft: rgba(229, 72, 77, 0.14);
--status-offline-border: rgba(229, 72, 77, 0.40);
--status-warn: #e8a33c;
--status-warn-soft: rgba(232, 163, 60, 0.15);
--status-warn-border: rgba(232, 163, 60, 0.40);
--status-info: #4c8df0;
--status-info-soft: rgba(76, 141, 240, 0.15);
--status-info-border: rgba(76, 141, 240, 0.40);
--status-starting: #2bc2d4; /* booting / updating / restarting */
--status-starting-soft: rgba(43, 194, 212, 0.15);
--status-starting-border: rgba(43, 194, 212, 0.40);
--status-wiping: #9b7bf0; /* Rust map wipe / maintenance */
--status-wiping-soft: rgba(155, 123, 240, 0.16);
--status-wiping-border: rgba(155, 123, 240, 0.40);
/* Aliases used by components */
--success: var(--status-online);
--danger: var(--status-offline);
--warning: var(--status-warn);
--info: var(--status-info);
/* Data-viz categorical (ECharts-friendly) */
--viz-1: var(--accent);
--viz-2: #4c8df0;
--viz-3: #36c780;
--viz-4: #9b7bf0;
--viz-5: #2bc2d4;
--viz-6: #e8a33c;
--viz-grid: var(--border-subtle);
}
/* ---- Semantic: LIGHT (full parity) ---- */
[data-theme="light"] {
--surface-canvas: #f5f6f8;
--surface-sunken: #eceef1;
--surface-base: #ffffff;
--surface-raised: #ffffff;
--surface-raised-2:#fbfcfd;
--surface-overlay: #ffffff;
--surface-inset: #f1f3f5;
--surface-hover: rgba(12, 16, 22, 0.04);
--surface-active: rgba(12, 16, 22, 0.07);
--surface-selected:rgba(12, 16, 22, 0.05);
--border-subtle: rgba(12, 16, 22, 0.07);
--border-default: rgba(12, 16, 22, 0.12);
--border-strong: rgba(12, 16, 22, 0.20);
--text-primary: #14171c;
--text-secondary: #474e58;
--text-tertiary: #6b727e;
--text-muted: #969ca6;
--text-inverse: #ffffff;
--scrim: rgba(16, 20, 28, 0.45);
/* Status soft fills need a touch more alpha on light */
--status-online-soft: rgba(33, 160, 98, 0.12);
--status-offline-soft: rgba(206, 44, 49, 0.10);
--status-warn-soft: rgba(193, 124, 18, 0.13);
--status-info-soft: rgba(43, 105, 214, 0.10);
--status-starting-soft: rgba(18, 150, 168, 0.12);
--status-wiping-soft: rgba(118, 86, 214, 0.12);
}

View File

@@ -0,0 +1,40 @@
/* ============================================================
Corrosion Control — Elevation, Borders, Glows
Shadows are quiet on dark surfaces; depth comes from layered
fills + hairline borders. Accent "glow" is reserved for
active/live states and game-themed focus.
============================================================ */
:root {
/* Hairline rings (use as box-shadow inset or border) */
--ring-subtle: inset 0 0 0 1px var(--border-subtle);
--ring-default: inset 0 0 0 1px var(--border-default);
--ring-strong: inset 0 0 0 1px var(--border-strong);
/* Drop shadows — tuned for dark; subtle and tight */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.40);
--shadow-md: 0 6px 18px -4px rgba(0, 0, 0, 0.50);
--shadow-lg: 0 18px 40px -10px rgba(0, 0, 0, 0.60);
--shadow-xl: 0 32px 70px -16px rgba(0, 0, 0, 0.70);
/* Popover / menu — combines ring + drop for crisp edges on dark */
--shadow-pop: 0 0 0 1px var(--border-default), 0 12px 36px -8px rgba(0, 0, 0, 0.66);
/* Accent glow — game-themed; used on live/active controls & focus */
--glow-accent: 0 0 0 1px var(--accent-border), 0 0 22px -2px var(--accent-glow);
--glow-accent-sm: 0 0 14px -2px var(--accent-glow);
--glow-online: 0 0 12px -1px rgba(54, 199, 128, 0.55);
/* Focus ring */
--focus-ring: 0 0 0 2px var(--surface-canvas), 0 0 0 4px var(--accent);
}
[data-theme="light"] {
--shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06);
--shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.08);
--shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.12);
--shadow-lg: 0 20px 44px -12px rgba(16, 20, 28, 0.16);
--shadow-xl: 0 32px 70px -18px rgba(16, 20, 28, 0.20);
--shadow-pop: 0 0 0 1px var(--border-default), 0 14px 38px -10px rgba(16, 20, 28, 0.20);
}

View File

@@ -0,0 +1,21 @@
/* ============================================================
Corrosion Control — Fonts
Geist — UI / body / app headings
JetBrains Mono — console, data, IDs, telemetry
Oxanium — brand wordmark + marketing display (game-ops flavor)
------------------------------------------------------------
NOTE: Loaded from Google Fonts CDN. If you want these self-
hosted (offline), send the woff2 files and these @imports
become @font-face rules.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
:root {
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
/* Brand wordmark + big marketing display — squared, technical, gamey */
--font-brand: 'Oxanium', 'Geist', system-ui, sans-serif;
/* App-level display headings stay on the neutral sans */
--font-display: var(--font-sans);
}

View File

@@ -0,0 +1,150 @@
/* ============================================================
Corrosion Control — Game Themes (the re-skin layer)
------------------------------------------------------------
The shell is neutral. Each game declares an ACCENT ramp + an
ATMOSPHERE (backdrop hues for hero headers, login, glows).
Set on <html data-game="rust"> (or dune / ark / valheim / ...).
Default (no data-game) = Corrosion brand = Oxide Orange.
Accent contract every game must define:
--accent base fill
--accent-hover hover fill
--accent-press pressed / darker
--accent-contrast text/icon ON the accent fill
--accent-text accent used AS text on dark surfaces (lightened for AA)
--accent-soft low-alpha tint background
--accent-soft-strong
--accent-border accent hairline
--accent-glow glow color (box-shadow)
--game-label printable name
--atmo-1 / --atmo-2 backdrop gradient stops
--atmo-haze radial haze color
============================================================ */
/* ---------- Default brand / RUST — Oxide Orange #F26622 ---------- */
:root,
[data-game="rust"] {
--accent: #f26622;
--accent-hover: #ff7a38;
--accent-press: #d9550f;
--accent-contrast: #190d05;
--accent-text: #ff8a4c;
--accent-soft: rgba(242, 102, 34, 0.13);
--accent-soft-strong: rgba(242, 102, 34, 0.22);
--accent-border: rgba(242, 102, 34, 0.42);
--accent-glow: rgba(242, 102, 34, 0.50);
--game-label: "Rust"; /* @kind other */
--atmo-1: #2c1206;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(242, 102, 34, 0.30);
}
/* ---------- DUNE: AWAKENING — Spice Amber / desert gold ---------- */
[data-game="dune"] {
--accent: #e9a53a;
--accent-hover: #f7b94f;
--accent-press: #c9851f;
--accent-contrast: #1c1303;
--accent-text: #f0bc5e;
--accent-soft: rgba(233, 165, 58, 0.14);
--accent-soft-strong: rgba(233, 165, 58, 0.24);
--accent-border: rgba(233, 165, 58, 0.44);
--accent-glow: rgba(233, 165, 58, 0.46);
--game-label: "Dune: Awakening"; /* @kind other */
--atmo-1: #2c1e08;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(233, 165, 58, 0.30);
}
/* ---------- SOULMASK: Ritual Jade ---------- */
[data-game="soulmask"] {
--accent: #43c47e;
--accent-hover: #59d792;
--accent-press: #2c9c5f;
--accent-contrast: #04140c;
--accent-text: #63d894;
--accent-soft: rgba(67, 196, 126, 0.14);
--accent-soft-strong: rgba(67, 196, 126, 0.24);
--accent-border: rgba(67, 196, 126, 0.42);
--accent-glow: rgba(67, 196, 126, 0.46);
--game-label: "Soulmask"; /* @kind other */
--atmo-1: #08231a;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(67, 196, 126, 0.26);
}
/* ---------- CONAN EXILES: Hyborian Bronze ---------- */
[data-game="conan"] {
--accent: #bb7637;
--accent-hover: #d28d4b;
--accent-press: #985c24;
--accent-contrast: #160d03;
--accent-text: #d59a5e;
--accent-soft: rgba(187, 118, 55, 0.15);
--accent-soft-strong: rgba(187, 118, 55, 0.26);
--accent-border: rgba(187, 118, 55, 0.44);
--accent-glow: rgba(187, 118, 55, 0.46);
--game-label: "Conan Exiles"; /* @kind other */
--atmo-1: #2a1b0b;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(187, 118, 55, 0.30);
}
/* ---------- Room to add more (stubs, ready to ship) ---------- */
[data-game="ark"] {
--accent: #36c2a8;
--accent-hover: #4ad7bd;
--accent-press: #1f9c86;
--accent-contrast: #04140f;
--accent-text: #54dcc2;
--accent-soft: rgba(54, 194, 168, 0.14);
--accent-soft-strong: rgba(54, 194, 168, 0.24);
--accent-border: rgba(54, 194, 168, 0.42);
--accent-glow: rgba(54, 194, 168, 0.46);
--game-label: "ARK"; /* @kind other */
--atmo-1: #07241f;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(54, 194, 168, 0.26);
}
[data-game="valheim"] {
--accent: #5b9bf0;
--accent-hover: #74afff;
--accent-press: #3c7ad4;
--accent-contrast: #050d1a;
--accent-text: #7fb2ff;
--accent-soft: rgba(91, 155, 240, 0.14);
--accent-soft-strong: rgba(91, 155, 240, 0.24);
--accent-border: rgba(91, 155, 240, 0.42);
--accent-glow: rgba(91, 155, 240, 0.46);
--game-label: "Valheim"; /* @kind other */
--atmo-1: #0a1c2e;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(91, 155, 240, 0.26);
}
[data-game="palworld"] {
--accent: #58b6e8;
--accent-hover: #71c8f6;
--accent-press: #3a92c4;
--accent-contrast: #04121b;
--accent-text: #7fcaf2;
--accent-soft: rgba(88, 182, 232, 0.14);
--accent-soft-strong: rgba(88, 182, 232, 0.24);
--accent-border: rgba(88, 182, 232, 0.42);
--accent-glow: rgba(88, 182, 232, 0.46);
--game-label: "Palworld"; /* @kind other */
--atmo-1: #08222e;
--atmo-2: #0a0b0e;
--atmo-haze: rgba(88, 182, 232, 0.26);
}
/* ---------- Light-theme accent legibility ----------
On light surfaces, accent-as-text reads better at the pressed
(darker) value. Placed last so it wins for --accent-text when
data-theme="light" and data-game=* are both on <html>. */
[data-theme="light"] {
--accent-text: var(--accent-press);
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
--accent-soft-strong: color-mix(in srgb, var(--accent) 20%, transparent);
}

View File

@@ -0,0 +1,27 @@
/* ============================================================
Corrosion Control — Motion
Fast, mechanical, precise. No bounce on chrome. Subtle spring
reserved for "live" telemetry (pulses, meters). Respect
prefers-reduced-motion in components.
============================================================ */
:root {
--dur-instant: 80ms; /* @kind other */
--dur-fast: 120ms; /* @kind other */
--dur-base: 170ms; /* @kind other */
--dur-slow: 240ms; /* @kind other */
--dur-slower: 360ms; /* @kind other */
/* Easings */
--ease-standard: cubic-bezier(0.2, 0, 0, 1); /* @kind other */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* @kind other */
--ease-in: cubic-bezier(0.5, 0, 0.84, 0); /* @kind other */
--ease-emphasized: cubic-bezier(0.34, 1.4, 0.5, 1); /* @kind other */
/* Common transition bundles */
--transition-colors: color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
--transition-transform: transform var(--dur-base) var(--ease-out);
}

View File

@@ -0,0 +1,50 @@
/* ============================================================
Corrosion Control — Spacing, Radius, Sizing
Dense ops-cockpit scale. 4px base grid, tightened.
============================================================ */
:root {
/* Space scale (px) — used for padding, gap, margins */
--space-0: 0;
--space-px: 1px;
--space-0-5: 2px;
--space-1: 4px;
--space-1-5: 6px;
--space-2: 8px;
--space-2-5: 10px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-14: 56px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
--space-32: 128px;
/* Radius — small & technical; cards stay crisp, not rounded-blobby */
--radius-xs: 3px;
--radius-sm: 5px;
--radius-md: 7px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 20px;
--radius-pill: 999px;
/* Control heights — compact rows for data-dense UI */
--control-h-xs: 24px;
--control-h-sm: 30px;
--control-h-md: 36px;
--control-h-lg: 44px;
/* Layout primitives */
--sidebar-w: 248px;
--sidebar-w-collapsed: 64px;
--topbar-h: 56px;
--content-max: 1440px;
--container-pad: var(--space-6);
}

View File

@@ -0,0 +1,75 @@
/* ============================================================
Corrosion Control — Typography
Geist for UI & display; JetBrains Mono for telemetry, IDs,
console, and any numeric/code data. Dense reading sizes.
============================================================ */
:root {
/* Font sizes (px) — base UI is 14 (dense) */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 38px;
--text-5xl: 50px;
--text-6xl: 66px;
--text-7xl: 88px;
/* Line heights */
--leading-none: 1;
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
/* Weights */
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-black: 800;
/* Letter spacing */
--tracking-tighter: -0.03em;
--tracking-tight: -0.015em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.06em;
--tracking-caps: 0.10em; /* eyebrows / overlines / labels */
/* ---- Semantic roles (font shorthands via custom props) ---- */
--font-display: var(--font-sans);
}
/* ---- Optional helper classes (cards/kits can use these) ---- */
.t-display {
font-family: var(--font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
}
.t-eyebrow {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-caps);
text-transform: uppercase;
color: var(--text-tertiary);
}
.t-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-normal);
}
.t-metric {
font-family: var(--font-mono);
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-tight);
}

View File

@@ -1,155 +1,497 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
/**
* DashboardView — Fleet / Solo dashboard.
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
* Solo: single-server detail wired to the real useServerStore where data exists.
*
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
* Routing stays at path '/', no new routes added.
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
import { useWipeStore } from '@/stores/wipe'
import { useThemeGame } from '@/composables/useThemeGame'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import ServerCard from '@/components/ds/data/ServerCard.vue'
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
import PlayersChart from '@/components/ds/data/PlayersChart.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 Tabs from '@/components/ds/navigation/Tabs.vue'
import Input from '@/components/ds/forms/Input.vue'
import Switch from '@/components/ds/forms/Switch.vue'
import {
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
type MockServer, type GameKey,
} from './_dashboardMock'
const router = useRouter()
const auth = useAuthStore()
// ---- Stores / composables ----
const server = useServerStore()
const wipe = useWipeStore()
const router = useRouter()
const { activeGame } = useThemeGame()
onMounted(async () => {
server.fetchServer()
try {
await wipe.fetchSchedules()
} catch {
// Non-critical — dashboard still loads without wipe data
}
})
// ---- View toggle ----
const VIEW_KEY = 'cc-dash-view'
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
function setView(v: string) {
view.value = v as 'fleet' | 'solo'
localStorage.setItem(VIEW_KEY, v)
}
const nextWipeDate = computed<string>(() => {
const upcoming = wipe.schedules
.filter(s => s.is_active && s.next_scheduled_run)
.map(s => new Date(s.next_scheduled_run!))
.sort((a, b) => a.getTime() - b.getTime())
const viewItems = [
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
]
if (upcoming.length === 0) return 'Not Scheduled'
// ---- Fleet: filter servers by activeGame ----
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
const statusItems = computed(() => [
{ value: 'all', label: 'All', count: inGame.value.length },
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
])
return upcoming[0]!.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
const inGame = computed<MockServer[]>(() =>
activeGame.value === 'all'
? MOCK_SERVERS
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
)
const shownServers = computed<MockServer[]>(() => {
const sv = serverStatus.value
return inGame.value.filter((s) => {
if (sv === 'all') return true
if (sv === 'online') return s.status !== 'offline'
return s.status === 'offline'
})
})
function statusColor(status: string | undefined): string {
switch (status) {
case 'connected': return 'bg-green-500'
case 'degraded': return 'bg-yellow-500'
default: return 'bg-red-500'
// ---- Fleet KPIs ----
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
const avgCpu = computed<string>(() =>
cpuValues.value.length
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
: '—',
)
const scopeLabel = computed(() =>
activeGame.value === 'all'
? 'Fleet overview'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
)
const fleetTitle = computed(() => {
if (activeGame.value === 'all') {
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
return `${MOCK_SERVERS.length} servers · ${games} games`
}
const n = inGame.value.length
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
return `${n} ${label} server${n === 1 ? '' : 's'}`
})
const chartSubtitle = computed(() =>
activeGame.value === 'all'
? 'All servers · last 24 hours'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
)
// ---- Chart period toggle ----
const chartPeriod = ref('24h')
const periodItems = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
]
// ---- Solo: real store data + representative fallbacks ----
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
const soloFps = computed(() => server.stats?.fps ?? 59.8)
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
// CPU: not in ServerStats; use representative value
const soloCpuPct = 41
// Status badge derived from connection_status
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online'
if (cs === 'degraded') return 'starting'
return 'offline'
})
const soloStatusTone = computed<'online' | 'offline' | 'starting' | 'warn'>(() => {
if (soloStatus.value === 'online') return 'online'
if (soloStatus.value === 'starting') return 'warn'
return 'offline'
})
const soloStatusLabel = computed(() => {
if (soloStatus.value === 'online') return 'Online'
if (soloStatus.value === 'starting') return 'Degraded'
return 'Offline'
})
const soloRegion = computed(() => {
const ip = server.connection?.server_ip
return ip ? 'Bare metal' : 'US-East'
})
const soloIp = computed(() => {
const ip = server.connection?.server_ip
const port = server.connection?.game_port ?? server.connection?.server_port
if (ip && port) return `${ip}:${port}`
return '89.142.0.7:28015'
})
const soloUptime = computed(() => {
const sec = server.stats?.uptime_seconds ?? 0
if (sec === 0) return '—'
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
return `${d}d ${h}h`
})
// Representative plugin list (uMod plugin state not in backend store)
const pluginStates = ref([
{ name: 'RaidableBases', ver: '2.7.4', on: true },
{ name: 'Kits', ver: '4.3.1', on: true },
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
{ name: 'Economics', ver: '3.9.6', on: true },
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
])
// Console input
const consoleInput = ref('')
function sendConsoleCommand() {
if (!consoleInput.value.trim()) return
server.sendCommand(consoleInput.value.trim()).catch(() => {})
consoleInput.value = ''
}
function statusLabel(status: string | undefined): string {
switch (status) {
case 'connected': return 'Online'
case 'degraded': return 'Degraded'
default: return 'Offline'
}
}
// Navigation helpers
function navConsole() { router.push('/console') }
function navWipes() { router.push('/wipes') }
function formatUptime(seconds: number | undefined): string {
if (!seconds) return '\u2014'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
// ---- Lifecycle ----
onMounted(() => {
server.fetchServer()
})
</script>
<template>
<div class="p-6 space-y-8">
<!-- Welcome header -->
<div>
<h1 class="text-2xl font-bold text-neutral-100">
Welcome back, {{ auth.user?.username }}
</h1>
<p class="text-sm text-neutral-500 mt-1">Here's what's happening with your server.</p>
</div>
<!-- Stat cards grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Server Status -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<p class="text-sm text-neutral-400 mb-2">Server Status</p>
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 rounded-full" :class="statusColor(server.connection?.connection_status)"></span>
<span class="text-2xl font-bold text-neutral-100">{{ statusLabel(server.connection?.connection_status) }}</span>
</div>
</div>
<!-- Players Online -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<p class="text-sm text-neutral-400 mb-2">Players Online</p>
<p class="text-2xl font-bold text-neutral-100">
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? server.config?.max_players ?? 0 }}
</p>
</div>
<!-- Next Wipe -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
<p class="text-2xl font-bold text-neutral-100">{{ nextWipeDate }}</p>
</div>
<!-- Uptime -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<p class="text-sm text-neutral-400 mb-2">Uptime</p>
<p class="text-2xl font-bold text-neutral-100">{{ formatUptime(server.stats?.uptime_seconds) }}</p>
</div>
</div>
<!-- Quick Actions -->
<div>
<h2 class="text-lg font-semibold text-neutral-200 mb-4">Quick Actions</h2>
<div class="flex flex-wrap gap-3">
<button
:disabled="server.connection?.connection_status === 'connected'"
@click="server.startServer()"
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
>
Start Server
</button>
<button
:disabled="server.connection?.connection_status !== 'connected'"
@click="server.stopServer()"
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
>
Stop Server
</button>
<button
@click="router.push('/wipes')"
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
>
Trigger Wipe
</button>
</div>
</div>
<!-- Server Info (if configured) -->
<div v-if="server.config" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-lg font-semibold text-neutral-200 mb-3">Server Configuration</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<div class="dash">
<!-- ===== FLEET VIEW ===== -->
<template v-if="view === 'fleet'">
<!-- Page head -->
<div class="page__head">
<div>
<span class="text-neutral-500">Server Name</span>
<p class="text-neutral-200 mt-0.5">{{ server.config.server_name || 'Not set' }}</p>
<div class="t-eyebrow">{{ scopeLabel }}</div>
<h1 class="page__title">{{ fleetTitle }}</h1>
</div>
<div>
<span class="text-neutral-500">Max Players</span>
<p class="text-neutral-200 mt-0.5">{{ server.config.max_players ?? 'Not set' }}</p>
</div>
<div>
<span class="text-neutral-500">World Size</span>
<p class="text-neutral-200 mt-0.5">{{ server.config.world_size ?? 'Not set' }}</p>
</div>
<div>
<span class="text-neutral-500">Current Seed</span>
<p class="text-neutral-200 mt-0.5">{{ server.config.current_seed ?? 'Not set' }}</p>
<div class="page__actions">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<Button variant="secondary" size="sm" icon="download">Export</Button>
<Button size="sm" icon="rocket">Deploy server</Button>
</div>
</div>
</div>
<!-- KPIs -->
<div class="dash__kpis">
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
</div>
<!-- Main grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart panel themed ECharts -->
<Panel title="Players online" :subtitle="chartSubtitle">
<template #actions>
<Tabs v-model="chartPeriod" :items="periodItems" />
</template>
<PlayersChart :height="200" :max="200" />
</Panel>
<!-- Servers list -->
<Panel :flush-body="true" title="Servers">
<template #actions>
<Tabs v-model="serverStatus" :items="statusItems" />
</template>
<div class="server__list">
<ServerCard
v-for="(s, i) in shownServers"
:key="i"
:game="s.game"
:game-icon="s.gameIcon"
:name="s.name"
:region="s.region"
:map="s.map"
:version="s.version"
:status="s.status"
:players="s.players"
:cpu="s.cpu"
:ram="s.ram"
:ram-sub="s.ramSub"
:ip="s.ip"
:stats="buildStats(s)"
/>
<div v-if="shownServers.length === 0" class="server__empty">
No servers match the current filter.
</div>
</div>
</Panel>
</div>
<!-- Right sidebar column -->
<div class="dash__col dash__col--side">
<!-- Live activity -->
<Panel :flush-body="true" title="Live activity">
<template #actions>
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
</template>
<div class="feed">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
</div>
</Panel>
<!-- Upcoming wipes -->
<Panel title="Upcoming wipes">
<div class="wipes">
<div
v-for="(w, i) in MOCK_WIPES"
:key="i"
class="wipe"
:data-game="w.game"
>
<div class="wipe__dot" />
<div class="wipe__body">
<div class="wipe__name">{{ w.name }}</div>
<div class="wipe__when">{{ w.when }}</div>
</div>
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
</div>
</div>
</Panel>
</div>
</div>
</template>
<!-- ===== SOLO VIEW ===== -->
<template v-else>
<!-- Page head -->
<div class="page__head">
<div class="solo-id">
<div class="solo-id__chip">
<Icon name="box" :size="21" :stroke-width="2" />
</div>
<div>
<div class="solo-id__name">
{{ soloName }}
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
</div>
<div class="solo-id__meta">
{{ soloRegion }} · {{ soloIp }}
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
</div>
</div>
</div>
<div class="page__actions">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
</div>
</div>
<!-- KPIs -->
<div class="dash__kpis">
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
</div>
<!-- Solo grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart themed ECharts -->
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
<PlayersChart :height="196" :max="soloMaxPlayers" />
</Panel>
<!-- Console panel -->
<Panel :flush-body="true" title="Console">
<template #actions>
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
</template>
<div class="feed feed--solo">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
</div>
<div class="console-bar">
<span class="console-bar__prompt">&gt;</span>
<Input
v-model="consoleInput"
:mono="true"
size="sm"
placeholder="say, kick, ban, oxide.reload …"
style="flex: 1"
@keydown.enter="sendConsoleCommand"
/>
<Button size="sm" variant="secondary" icon="corner-down-left" @click="sendConsoleCommand">Send</Button>
</div>
</Panel>
</div>
<!-- Right sidebar -->
<div class="dash__col dash__col--side">
<!-- Resources -->
<Panel title="Resources" subtitle="Companion agent telemetry">
<div class="solo-meters">
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
<ResourceMeter label="Disk" :value="64" sub="representative" />
</div>
</Panel>
<!-- Plugins -->
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
<template #actions>
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
</template>
<div class="plugs">
<div
v-for="(p, i) in pluginStates"
:key="i"
class="plug"
>
<div class="plug__id">
<span class="plug__name">{{ p.name }}</span>
<span class="plug__ver">{{ p.ver }}</span>
</div>
<Switch v-model="p.on" size="sm" />
</div>
</div>
</Panel>
<!-- Next wipe -->
<Panel title="Next wipe">
<div class="solo-wipe">
<div>
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
<div class="solo-wipe__sub">representative configure in wipe manager</div>
</div>
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
</div>
</Panel>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ---------- Shared shell ---------- */
.dash { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.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; white-space: nowrap;
}
.page__actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.dash__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
/* ---------- Servers list ---------- */
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
/* ---------- Live feed ---------- */
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
.feed--solo { max-height: 230px; }
/* ---------- Upcoming wipes ---------- */
.wipes { display: flex; flex-direction: column; gap: 4px; }
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
.wipe:hover { background: var(--surface-hover); }
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
.wipe__body { flex: 1; min-width: 0; }
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
/* ---------- Solo identity header ---------- */
.solo-id { display: flex; align-items: center; gap: 13px; }
.solo-id__chip {
width: 42px; height: 42px; 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);
}
.solo-id__name {
display: flex; align-items: center; gap: 10px;
font-size: var(--text-xl); font-weight: 700; letter-spacing: -0.01em;
color: var(--text-primary); white-space: nowrap;
}
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
/* ---------- Console bar ---------- */
.console-bar {
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
border-top: 1px solid var(--border-subtle); background: var(--surface-inset);
}
.console-bar__prompt { font-family: var(--font-mono); color: var(--accent-text); font-weight: 700; }
/* ---------- Resources ---------- */
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
/* ---------- Plugin list ---------- */
.plugs { display: flex; flex-direction: column; }
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
.plug:last-child { border-bottom: 0; }
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
/* ---------- Next wipe ---------- */
.solo-wipe { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
/* ---------- Responsive ---------- */
@media (max-width: 1180px) {
.dash__grid { grid-template-columns: 1fr; }
.server__list { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
}
</style>

View File

@@ -0,0 +1,159 @@
/**
* Dashboard mock data — representative placeholder pending multi-instance backend.
* Current backend is single-server-per-license; the fleet view is a forward-looking
* surface that will bind to a multi-instance API. All data here is static and clearly
* labeled so it is never confused for real tenant data.
*
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
export interface MockServer {
game: GameKey
gameIcon: string
name: string
region: string
map: string
version: string
status: ServerStatus
players: { cur: number; max: number }
cpu?: number
ram?: number
ramSub?: string
ip: string
// Rust-only
umod?: string
wipe?: string
// Dune-only
sietches?: string
control?: string
// Conan-only
clans?: string
purge?: string
// Soulmask-only
tribe?: string
mask?: string
}
export interface MockFeedLine {
time: string
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
who?: string
msg: string
}
export interface MockWipe {
game: GameKey
name: string
when: string
tone: 'wiping' | 'starting' | 'warn' | 'online'
label: string
}
export interface StatItem {
label: string
value: string | number
}
// ---------------------------------------------------------------------------
// Fleet server roster
// ---------------------------------------------------------------------------
export const MOCK_SERVERS: MockServer[] = [
{
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
map: 'Barren 3000', version: 'v2024.12', status: 'online',
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
},
{
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
},
{
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
},
{
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
},
{
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
},
{
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
},
]
// ---------------------------------------------------------------------------
// Per-game stat field sets — never share slots across games
// ---------------------------------------------------------------------------
function pl(s: MockServer): string {
return `${s.players.cur} / ${s.players.max}`
}
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
}
export function buildStats(s: MockServer): StatItem[] {
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
return fn(s)
}
// ---------------------------------------------------------------------------
// Live activity feed
// ---------------------------------------------------------------------------
export const MOCK_FEED: MockFeedLine[] = [
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
]
// ---------------------------------------------------------------------------
// Upcoming wipes
// ---------------------------------------------------------------------------
export const MOCK_WIPES: MockWipe[] = [
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
]