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
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:
@@ -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>
|
||||
|
||||
8
frontend/src/assets/corrosion-mark.svg
Normal file
8
frontend/src/assets/corrosion-mark.svg
Normal 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 |
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
29
frontend/src/components/brand/CorrosionMark.vue
Normal 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>
|
||||
92
frontend/src/components/ds/brand/Logo.vue
Normal file
92
frontend/src/components/ds/brand/Logo.vue
Normal 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>
|
||||
62
frontend/src/components/ds/core/Badge.vue
Normal file
62
frontend/src/components/ds/core/Badge.vue
Normal 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>
|
||||
82
frontend/src/components/ds/core/Button.vue
Normal file
82
frontend/src/components/ds/core/Button.vue
Normal 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>
|
||||
73
frontend/src/components/ds/core/Icon.vue
Normal file
73
frontend/src/components/ds/core/Icon.vue
Normal 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>
|
||||
62
frontend/src/components/ds/core/IconButton.vue
Normal file
62
frontend/src/components/ds/core/IconButton.vue
Normal 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>
|
||||
20
frontend/src/components/ds/core/Kbd.vue
Normal file
20
frontend/src/components/ds/core/Kbd.vue
Normal 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>
|
||||
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
48
frontend/src/components/ds/core/StatusDot.vue
Normal 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>
|
||||
52
frontend/src/components/ds/core/Tag.vue
Normal file
52
frontend/src/components/ds/core/Tag.vue
Normal 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>
|
||||
72
frontend/src/components/ds/data/Avatar.vue
Normal file
72
frontend/src/components/ds/data/Avatar.vue
Normal 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>
|
||||
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal 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>
|
||||
57
frontend/src/components/ds/data/Panel.vue
Normal file
57
frontend/src/components/ds/data/Panel.vue
Normal 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>
|
||||
98
frontend/src/components/ds/data/PlayersChart.vue
Normal file
98
frontend/src/components/ds/data/PlayersChart.vue
Normal 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>
|
||||
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal 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>
|
||||
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
166
frontend/src/components/ds/data/ServerCard.vue
Normal 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>
|
||||
62
frontend/src/components/ds/data/StatCard.vue
Normal file
62
frontend/src/components/ds/data/StatCard.vue
Normal 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>
|
||||
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
86
frontend/src/components/ds/feedback/Alert.vue
Normal 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>
|
||||
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal 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>
|
||||
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
50
frontend/src/components/ds/forms/Checkbox.vue
Normal 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>
|
||||
90
frontend/src/components/ds/forms/Input.vue
Normal file
90
frontend/src/components/ds/forms/Input.vue
Normal 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>
|
||||
86
frontend/src/components/ds/forms/Select.vue
Normal file
86
frontend/src/components/ds/forms/Select.vue
Normal 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>
|
||||
59
frontend/src/components/ds/forms/Switch.vue
Normal file
59
frontend/src/components/ds/forms/Switch.vue
Normal 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>
|
||||
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal 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>
|
||||
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
50
frontend/src/components/ds/navigation/NavItem.vue
Normal 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>
|
||||
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
67
frontend/src/components/ds/navigation/Tabs.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
114
frontend/src/composables/useThemeGame.ts
Normal file
114
frontend/src/composables/useThemeGame.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
15
frontend/src/styles/corrosion.css
Normal file
15
frontend/src/styles/corrosion.css
Normal 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");
|
||||
75
frontend/src/styles/tokens/base.css
Normal file
75
frontend/src/styles/tokens/base.css
Normal 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; }
|
||||
136
frontend/src/styles/tokens/colors.css
Normal file
136
frontend/src/styles/tokens/colors.css
Normal 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);
|
||||
}
|
||||
40
frontend/src/styles/tokens/elevation.css
Normal file
40
frontend/src/styles/tokens/elevation.css
Normal 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);
|
||||
}
|
||||
21
frontend/src/styles/tokens/fonts.css
Normal file
21
frontend/src/styles/tokens/fonts.css
Normal 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);
|
||||
}
|
||||
150
frontend/src/styles/tokens/game-themes.css
Normal file
150
frontend/src/styles/tokens/game-themes.css
Normal 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);
|
||||
}
|
||||
27
frontend/src/styles/tokens/motion.css
Normal file
27
frontend/src/styles/tokens/motion.css
Normal 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);
|
||||
}
|
||||
50
frontend/src/styles/tokens/spacing.css
Normal file
50
frontend/src/styles/tokens/spacing.css
Normal 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);
|
||||
}
|
||||
75
frontend/src/styles/tokens/typography.css
Normal file
75
frontend/src/styles/tokens/typography.css
Normal 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);
|
||||
}
|
||||
@@ -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">></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>
|
||||
|
||||
159
frontend/src/views/admin/_dashboardMock.ts
Normal file
159
frontend/src/views/admin/_dashboardMock.ts
Normal 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' },
|
||||
]
|
||||
Reference in New Issue
Block a user