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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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