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:
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>
|
||||
|
||||
Reference in New Issue
Block a user